rebuild(stage-8): deliver market insight workspaces
This commit is contained in:
@@ -18,7 +18,10 @@ const locked = computed(
|
||||
() => ["screener", "mentor", "heaven"].includes(workspace.value.key) && !session.account?.smart_access,
|
||||
);
|
||||
const implementedMarket = computed(() =>
|
||||
["emotion", "pool", "broken", "limit-down", "yesterday", "performance", "ladder", "rotation"].includes(
|
||||
[
|
||||
"emotion", "pool", "broken", "limit-down", "yesterday", "performance", "ladder",
|
||||
"rotation", "auction", "themes", "popularity", "dragon-list",
|
||||
].includes(
|
||||
workspace.value.key,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ import "./shared/styles/auth.css";
|
||||
import "./shared/styles/account.css";
|
||||
import "./shared/styles/market.css";
|
||||
import "./shared/styles/market-workspace.css";
|
||||
import "./shared/styles/market-insights.css";
|
||||
import "./shared/styles/system.css";
|
||||
import "./shared/styles/mobile.css";
|
||||
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from "vue";
|
||||
import { computed, ref, watch } from "vue";
|
||||
|
||||
import { marketApi, type MarketWorkspaceData } from "../../shared/api/market";
|
||||
import { marketApi, type MarketInsightData, 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 AuctionPage from "./insights/AuctionPage.vue";
|
||||
import DragonPage from "./insights/DragonPage.vue";
|
||||
import PopularityPage from "./insights/PopularityPage.vue";
|
||||
import ThemesPage from "./insights/ThemesPage.vue";
|
||||
import PerformancePage from "./pools/PerformancePage.vue";
|
||||
import PoolPage from "./pools/PoolPage.vue";
|
||||
|
||||
const props = defineProps<{ workspaceKey: string }>();
|
||||
const market = useMarketStore();
|
||||
const data = ref<MarketWorkspaceData | null>(null);
|
||||
const data = ref<MarketWorkspaceData | MarketInsightData | null>(null);
|
||||
const insightData = computed(() => data.value as MarketInsightData);
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
let sequence = 0;
|
||||
@@ -22,7 +27,9 @@ async function load(): Promise<void> {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const result = await marketApi.workspace(props.workspaceKey, market.selectedDate);
|
||||
const result = ["auction", "themes", "popularity", "dragon-list"].includes(props.workspaceKey)
|
||||
? await marketApi.insight(props.workspaceKey, market.selectedDate)
|
||||
: await marketApi.workspace(props.workspaceKey, market.selectedDate);
|
||||
if (current === sequence) data.value = result;
|
||||
} catch (reason) {
|
||||
if (current === sequence) error.value = reason instanceof Error ? reason.message : "页面数据读取失败";
|
||||
@@ -42,6 +49,10 @@ watch([() => props.workspaceKey, () => market.selectedDate], () => void load(),
|
||||
<EmotionPage v-else-if="workspaceKey === 'emotion'" :data="data" />
|
||||
<LadderPage v-else-if="workspaceKey === 'ladder'" :data="data" />
|
||||
<RotationPage v-else-if="workspaceKey === 'rotation'" :data="data" />
|
||||
<AuctionPage v-else-if="workspaceKey === 'auction'" :data="insightData" />
|
||||
<ThemesPage v-else-if="workspaceKey === 'themes'" :data="insightData" />
|
||||
<PopularityPage v-else-if="workspaceKey === 'popularity'" :data="insightData" />
|
||||
<DragonPage v-else-if="workspaceKey === 'dragon-list'" :data="insightData" @refresh="load" />
|
||||
<PerformancePage v-else-if="workspaceKey === 'performance'" :data="data" />
|
||||
<PoolPage v-else :kind="workspaceKey" :data="data" />
|
||||
</main>
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
|
||||
import type { MarketInsightData } from "../../../shared/api/market";
|
||||
import DataTable from "../../../shared/components/DataTable.vue";
|
||||
import EmptyState from "../../../shared/components/EmptyState.vue";
|
||||
import { exportCsv, formatNumber, sortRows, type SortDirection } from "../../../shared/market/table";
|
||||
|
||||
const props = defineProps<{ data: MarketInsightData }>();
|
||||
const dataset = ref<"focus" | "watchlist" | "all" | "one-price">("focus");
|
||||
const expectation = ref("all");
|
||||
const query = ref("");
|
||||
const sortKey = ref("attention_score");
|
||||
const sortDirection = ref<SortDirection>("desc");
|
||||
const summary = computed(() => props.data.summary ?? {});
|
||||
const phaseLabels = {
|
||||
pending: "待开始",
|
||||
observing: "观察中",
|
||||
selection: "筛选确认",
|
||||
finalized: "已归档",
|
||||
archive: "历史归档",
|
||||
};
|
||||
const sources = computed(() => ({
|
||||
focus: props.data.focus_rows ?? [],
|
||||
watchlist: props.data.watchlist_rows ?? [],
|
||||
all: props.data.rows ?? [],
|
||||
"one-price": props.data.one_price_rows ?? [],
|
||||
}));
|
||||
const rows = computed(() => {
|
||||
const normalized = query.value.trim().toLocaleLowerCase();
|
||||
const selected = sources.value[dataset.value].filter((row) => {
|
||||
if (expectation.value !== "all" && row.expectation !== expectation.value) return false;
|
||||
if (!normalized) return true;
|
||||
return [row.code, row.name, row.sector, row.source_label]
|
||||
.some((value) => String(value ?? "").toLocaleLowerCase().includes(normalized));
|
||||
});
|
||||
return sortRows(selected, sortKey.value, sortDirection.value);
|
||||
});
|
||||
const columns = [
|
||||
{ key: "code", label: "代码", code: true, sortable: true },
|
||||
{ key: "name", label: "股票", sortable: true },
|
||||
{ key: "source_label", label: "候选身份", wide: true, sortable: true },
|
||||
{ key: "sector", label: "板块", sortable: true },
|
||||
{ key: "expectation", label: "预期", sortable: true },
|
||||
{ key: "change", label: "竞价涨幅(%)", numeric: true, sortable: true, format: number },
|
||||
{ key: "expected_change", label: "预期中枢(%)", numeric: true, sortable: true, format: number },
|
||||
{ key: "attention_score", label: "关注分", numeric: true, sortable: true, format: oneDecimal },
|
||||
{ key: "volume_ratio", label: "量比", numeric: true, sortable: true, format: number },
|
||||
{ key: "amount_million", label: "竞价额(百万)", numeric: true, sortable: true, format: number },
|
||||
];
|
||||
const maxAmount = computed(() => Math.max(1, ...(props.data.amount_history ?? []).map((row) => Number(row.amount_billion ?? 0))));
|
||||
const fiveDayAverage = computed(() => {
|
||||
const values = (props.data.amount_history ?? []).slice(-5).map((row) => Number(row.amount_billion ?? 0));
|
||||
return values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : 0;
|
||||
});
|
||||
|
||||
function sort(key: string): void {
|
||||
if (sortKey.value === key) sortDirection.value = sortDirection.value === "asc" ? "desc" : "asc";
|
||||
else { sortKey.value = key; sortDirection.value = "desc"; }
|
||||
}
|
||||
function number(value: unknown): string { return formatNumber(value, 2); }
|
||||
function oneDecimal(value: unknown): string { return formatNumber(value, 1); }
|
||||
function download(): void {
|
||||
const lines = [["代码", "股票", "身份", "板块", "预期", "竞价涨幅(%)", "预期中枢(%)", "关注分", "量比", "竞价额(百万)"]];
|
||||
for (const row of rows.value) lines.push([
|
||||
String(row.code ?? ""), String(row.name ?? ""), String(row.source_label ?? ""),
|
||||
String(row.sector ?? ""), String(row.expectation ?? ""), String(row.change ?? ""),
|
||||
String(row.expected_change ?? ""), String(row.attention_score ?? ""),
|
||||
String(row.volume_ratio ?? ""), String(row.amount_million ?? ""),
|
||||
]);
|
||||
exportCsv(`集合竞价-${props.data.trade_date}.csv`, lines);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="page-header market-page-header">
|
||||
<div><h1>集合竞价中心</h1><p class="page-subtitle">{{ phaseLabels[data.phase ?? "archive"] }} · 数据日期 {{ data.trade_date }}</p></div>
|
||||
<span class="tag" :class="{ warning: !data.current_available }">{{ data.message || `竞价覆盖 ${Number(data.coverage ?? 0) * 100}%` }}</span>
|
||||
</header>
|
||||
<section class="auction-layout">
|
||||
<article class="card auction-main">
|
||||
<div class="auction-dataset-bar">
|
||||
<div class="seg-control auction-datasets">
|
||||
<button type="button" :class="{ active: dataset === 'focus' }" @click="dataset = 'focus'">重点异动 <b>{{ data.focus_rows?.length ?? 0 }}</b></button>
|
||||
<button type="button" :class="{ active: dataset === 'watchlist' }" @click="dataset = 'watchlist'">我的自选 <b>{{ data.watchlist_rows?.length ?? 0 }}</b></button>
|
||||
<button type="button" :class="{ active: dataset === 'all' }" @click="dataset = 'all'">全部候选 <b>{{ data.rows?.length ?? 0 }}</b></button>
|
||||
<button type="button" :class="{ active: dataset === 'one-price' }" @click="dataset = 'one-price'">竞价一字 <b>{{ data.one_price_rows?.length ?? 0 }}</b></button>
|
||||
</div>
|
||||
<dl class="auction-inline-summary">
|
||||
<div><dt>竞价覆盖</dt><dd>{{ summary.stock_count ?? 0 }}</dd></div>
|
||||
<div><dt>重点异动</dt><dd>{{ summary.focus_count ?? 0 }}</dd></div>
|
||||
<div><dt>竞价一字</dt><dd>{{ summary.one_price_count ?? 0 }}</dd></div>
|
||||
<div><dt>竞价成交额</dt><dd>{{ number(summary.amount_billion) }} 亿</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="auction-toolbar">
|
||||
<div class="seg-control">
|
||||
<button v-for="item in [['all','全部'],['超预期','超预期'],['符合预期','符合预期'],['低于预期','低于预期']]" :key="item[0]" type="button" :class="{ active: expectation === item[0] }" @click="expectation = item[0]">{{ item[1] }}</button>
|
||||
</div>
|
||||
<label class="search-control"><span>搜索</span><input v-model="query" type="search" placeholder="代码、名称或板块"></label>
|
||||
<button class="btn btn-small" type="button" @click="download">导出 CSV</button>
|
||||
</div>
|
||||
<DataTable v-if="rows.length" :columns="columns" :rows="rows" :sort-key="sortKey" :sort-direction="sortDirection" @sort="sort" />
|
||||
<EmptyState v-else title="没有符合条件的竞价候选" :description="dataset === 'watchlist' ? '当前账号还没有可用的自选竞价结果。' : '调整预期筛选或搜索条件后再查看。'" />
|
||||
</article>
|
||||
<aside class="auction-side">
|
||||
<article class="card"><header class="card-header"><h2>题材承接</h2><span class="faint">昨日强势方向</span></header><div class="auction-evidence-list"><div v-for="item in data.themes?.carry ?? []" :key="String(item.name)"><strong>{{ item.name }}</strong><span>{{ item.status }}</span><small>{{ item.matched_count }}只 · 中位 {{ item.median_change ?? '' }}%</small></div><p v-if="!data.themes?.carry?.length" class="faint">暂无可核验承接结果</p></div></article>
|
||||
<article class="card"><header class="card-header"><h2>今日新线索</h2></header><div class="auction-evidence-list"><div v-for="item in data.themes?.new_themes ?? []" :key="String(item.name)"><strong>{{ item.name }}</strong><span>{{ item.stock_count }}只</span><small>{{ (item.leaders as string[])?.join('、') }}</small></div><p v-if="!data.themes?.new_themes?.length" class="faint">尚未形成新的聚集方向</p></div></article>
|
||||
<article class="card auction-amount-card"><header class="card-header"><h2>竞价成交额对比</h2><span class="faint">5日均值 {{ fiveDayAverage.toFixed(2) }} 亿</span></header><div class="auction-bars"><div v-for="item in data.amount_history ?? []" :key="String(item.trade_date)"><span>{{ String(item.trade_date).slice(5) }}</span><i><b :style="{ width: `${Number(item.amount_billion ?? 0) / maxAmount * 100}%` }"></b></i><strong>{{ number(item.amount_billion) }}</strong></div></div></article>
|
||||
</aside>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,110 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from "vue";
|
||||
|
||||
import { marketApi, type MarketInsightData } from "../../../shared/api/market";
|
||||
import DataTable from "../../../shared/components/DataTable.vue";
|
||||
import EmptyState from "../../../shared/components/EmptyState.vue";
|
||||
import { formatNumber, sortRows, type SortDirection } from "../../../shared/market/table";
|
||||
import { useSessionStore } from "../../../shared/stores/session";
|
||||
import { useUiStore } from "../../../shared/stores/ui";
|
||||
|
||||
const props = defineProps<{ data: MarketInsightData }>();
|
||||
const emit = defineEmits<{ refresh: [] }>();
|
||||
const session = useSessionStore();
|
||||
const ui = useUiStore();
|
||||
const view = ref<"daily" | "profiles">("daily");
|
||||
const filter = ref<"all" | "buy" | "sell" | "pending">("all");
|
||||
const query = ref("");
|
||||
const selectedTrader = ref("");
|
||||
const selectedProfile = ref("");
|
||||
const sortKey = ref("net_million");
|
||||
const sortDirection = ref<SortDirection>("desc");
|
||||
const aliases = reactive<Record<string, string>>({});
|
||||
const savingSeat = ref("");
|
||||
const traders = computed(() => props.data.traders ?? []);
|
||||
const profiles = computed(() => props.data.profiles ?? []);
|
||||
const profile = computed(() => profiles.value.find((item) => item.name === selectedProfile.value) ?? profiles.value[0]);
|
||||
const operations = computed(() => {
|
||||
const normalized = query.value.trim().toLocaleLowerCase();
|
||||
const rows = (props.data.operations ?? []).filter((row) => {
|
||||
if (selectedTrader.value && row.trader_name !== selectedTrader.value) return false;
|
||||
if (filter.value === "buy" && Number(row.net_million ?? 0) <= 0) return false;
|
||||
if (filter.value === "sell" && Number(row.net_million ?? 0) >= 0) return false;
|
||||
if (filter.value === "pending" && row.recognized) return false;
|
||||
if (!normalized) return true;
|
||||
return [row.code, row.name, row.seat_name, row.trader_name, row.reason]
|
||||
.some((value) => String(value ?? "").toLocaleLowerCase().includes(normalized));
|
||||
});
|
||||
return sortRows(rows, sortKey.value, sortDirection.value);
|
||||
});
|
||||
const columns = [
|
||||
{ key: "code", label: "代码", code: true, sortable: true },
|
||||
{ key: "name", label: "股票", sortable: true },
|
||||
{ key: "direction", label: "方向", sortable: true },
|
||||
{ key: "buy_million", label: "买入(百万)", numeric: true, sortable: true, format: number },
|
||||
{ key: "sell_million", label: "卖出(百万)", numeric: true, sortable: true, format: number },
|
||||
{ key: "net_million", label: "净额(百万)", numeric: true, sortable: true, format: number },
|
||||
{ key: "trader_name", label: "游资", sortable: true },
|
||||
{ key: "seat_name", label: "营业部", wide: true, sortable: true },
|
||||
{ key: "reason", label: "上榜原因", wide: true },
|
||||
];
|
||||
|
||||
watch(profiles, (items) => {
|
||||
if (!selectedProfile.value && items[0]) selectedProfile.value = String(items[0].name ?? "");
|
||||
}, { immediate: true });
|
||||
|
||||
function sort(key: string): void {
|
||||
if (sortKey.value === key) sortDirection.value = sortDirection.value === "asc" ? "desc" : "asc";
|
||||
else { sortKey.value = key; sortDirection.value = "desc"; }
|
||||
}
|
||||
function number(value: unknown): string { return formatNumber(value, 2); }
|
||||
async function saveAlias(seat: string): Promise<void> {
|
||||
const alias = aliases[seat]?.trim();
|
||||
if (!alias) return;
|
||||
savingSeat.value = seat;
|
||||
try {
|
||||
await marketApi.saveSeatAlias(seat, alias);
|
||||
ui.showToast("席位归类已保存");
|
||||
emit("refresh");
|
||||
} catch (reason) {
|
||||
ui.showToast(reason instanceof Error ? reason.message : "席位归类保存失败");
|
||||
} finally {
|
||||
savingSeat.value = "";
|
||||
}
|
||||
}
|
||||
</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: view === 'daily' }" @click="view = 'daily'">每日明细</button><button type="button" :class="{ active: view === 'profiles' }" @click="view = 'profiles'">游资档案</button></div></div>
|
||||
</header>
|
||||
<template v-if="view === 'daily'">
|
||||
<section class="dragon-summary-grid">
|
||||
<article class="card"><span>上榜股票</span><strong>{{ data.summary.official_stock_count ?? 0 }}</strong></article>
|
||||
<article class="card"><span>活跃游资</span><strong>{{ data.summary.trader_count ?? 0 }}</strong></article>
|
||||
<article class="card"><span>操作明细</span><strong>{{ data.summary.operation_count ?? 0 }}</strong></article>
|
||||
<article class="card"><span>待归类席位</span><strong>{{ data.summary.unclassified_count ?? 0 }}</strong></article>
|
||||
<article class="card"><span>席位净额</span><strong>{{ number(data.summary.net_million) }} 百万</strong></article>
|
||||
</section>
|
||||
<div v-if="data.message" class="notice" :class="data.status === 'unavailable' ? 'notice-warning' : ''">{{ data.message }}<span v-if="data.previous_date"> · 可切换至 {{ data.previous_date }} 查看</span></div>
|
||||
<section v-if="data.status !== 'empty' && data.status !== 'detail_missing' && data.status !== 'unavailable'" class="dragon-daily-layout">
|
||||
<aside class="card dragon-traders">
|
||||
<header class="card-header"><h2>活跃游资</h2><button v-if="selectedTrader" class="btn btn-small" type="button" @click="selectedTrader = ''">显示全部</button></header>
|
||||
<div class="dragon-trader-list"><button v-for="trader in traders" :key="String(trader.name)" type="button" :class="{ active: selectedTrader === trader.name }" @click="selectedTrader = String(trader.name)"><span><strong>{{ trader.name }}</strong><small>{{ trader.description || '暂无简介' }}</small></span><b :class="Number(trader.net_million) >= 0 ? 'up' : 'down'">{{ number(trader.net_million) }}</b></button><p v-if="!traders.length" class="faint">当前没有已识别游资</p></div>
|
||||
<section v-if="data.unclassified_seats?.length" class="dragon-pending"><header><strong>待归类营业部</strong><span>{{ data.unclassified_seats.length }}个</span></header><div v-for="seat in data.unclassified_seats" :key="String(seat.seat_name)"><p><span>{{ seat.seat_name }}</span><b>{{ number(seat.net_million) }}</b></p><form v-if="session.isAdmin" @submit.prevent="saveAlias(String(seat.seat_name))"><input v-model="aliases[String(seat.seat_name)]" type="text" placeholder="归类为游资名称"><button class="btn btn-small" type="submit" :disabled="savingSeat === seat.seat_name">保存</button></form></div></section>
|
||||
</aside>
|
||||
<article class="card dragon-operations">
|
||||
<header class="card-header"><h2>当日操作明细</h2><span class="faint">{{ operations.length }}条</span></header>
|
||||
<div class="dragon-toolbar"><div class="seg-control"><button type="button" :class="{ active: filter === 'all' }" @click="filter = 'all'">全部</button><button type="button" :class="{ active: filter === 'buy' }" @click="filter = 'buy'">净买入</button><button type="button" :class="{ active: filter === 'sell' }" @click="filter = 'sell'">净卖出</button><button type="button" :class="{ active: filter === 'pending' }" @click="filter = 'pending'">待归类</button></div><label class="search-control"><span>搜索</span><input v-model="query" type="search" placeholder="代码、股票、游资或营业部"></label></div>
|
||||
<div class="dragon-table-scroll"><DataTable v-if="operations.length" :columns="columns" :rows="operations" :sort-key="sortKey" :sort-direction="sortDirection" @sort="sort" /><EmptyState v-else title="暂无符合条件的操作明细" description="调整筛选条件或选择其他活跃游资。" /></div>
|
||||
</article>
|
||||
</section>
|
||||
<EmptyState v-else class="card" :title="data.status === 'empty' ? '当日没有股票上榜' : data.status === 'detail_missing' ? '席位明细尚未返回' : '龙虎榜数据暂不可用'" :description="data.message" />
|
||||
</template>
|
||||
<section v-else class="card dragon-profiles">
|
||||
<aside><header class="card-header"><h2>游资名录</h2><span class="faint">{{ profiles.length }}位</span></header><div><button v-for="item in profiles" :key="String(item.name)" type="button" :class="{ active: profile?.name === item.name }" @click="selectedProfile = String(item.name)"><strong>{{ item.name }}</strong><small>{{ item.organization_count }}个关联营业部</small></button></div></aside>
|
||||
<article v-if="profile"><header><div><h2>{{ profile.name }}</h2><p>{{ profile.description || '暂无公开简介' }}</p></div><span class="tag">{{ profile.organization_count }}个营业部</span></header><section><h3>关联营业部</h3><ul><li v-for="organization in profile.organizations as string[]" :key="organization">{{ organization }}</li></ul></section><p class="notice">档案统计以当前可用名录和已归类席位为准,覆盖范围会随归档累积。</p></article>
|
||||
<EmptyState v-else title="游资名录暂不可用" description="当前没有可核验的游资档案。" />
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
|
||||
import type { MarketInsightData } from "../../../shared/api/market";
|
||||
import DataTable from "../../../shared/components/DataTable.vue";
|
||||
import EmptyState from "../../../shared/components/EmptyState.vue";
|
||||
import { formatNumber, sortRows, type SortDirection } from "../../../shared/market/table";
|
||||
|
||||
const props = defineProps<{ data: MarketInsightData }>();
|
||||
const source = ref<"combined" | "ths" | "dc">("combined");
|
||||
const query = ref("");
|
||||
const sortKey = ref("rank");
|
||||
const sortDirection = ref<SortDirection>("asc");
|
||||
const sourceRows = computed(() => props.data[source.value] ?? []);
|
||||
const rows = computed(() => {
|
||||
const normalized = query.value.trim().toLocaleLowerCase();
|
||||
const filtered = sourceRows.value.filter((row) => !normalized || [row.code, row.name, ...(row.concepts as unknown[] ?? [])]
|
||||
.some((value) => String(value ?? "").toLocaleLowerCase().includes(normalized)));
|
||||
return sortRows(filtered, sortKey.value, sortDirection.value);
|
||||
});
|
||||
const columns = [
|
||||
{ key: "rank", label: "排名", numeric: true, sortable: true },
|
||||
{ key: "code", label: "代码", code: true, sortable: true },
|
||||
{ key: "name", label: "股票", sortable: true },
|
||||
{ key: "change", label: "涨跌幅(%)", numeric: true, sortable: true, format: number },
|
||||
{ key: "ths_rank", label: "同花顺排名", numeric: true, sortable: true },
|
||||
{ key: "dc_rank", label: "东方财富排名", numeric: true, sortable: true },
|
||||
{ key: "rank_change", label: "排名变化", numeric: true, sortable: true, format: signed },
|
||||
{ key: "concepts", label: "相关题材", wide: true, format: concepts },
|
||||
{ key: "reason", label: "上榜线索", wide: true },
|
||||
];
|
||||
const topThs = computed(() => (props.data.ths ?? []).slice(0, 3));
|
||||
const topDc = computed(() => (props.data.dc ?? []).slice(0, 3));
|
||||
const consensus = computed(() => (props.data.combined ?? []).filter((row) => row.dual_source).slice(0, 3));
|
||||
|
||||
function sort(key: string): void {
|
||||
if (sortKey.value === key) sortDirection.value = sortDirection.value === "asc" ? "desc" : "asc";
|
||||
else { sortKey.value = key; sortDirection.value = key === "rank" ? "asc" : "desc"; }
|
||||
}
|
||||
function number(value: unknown): string { return formatNumber(value, 2); }
|
||||
function signed(value: unknown): string {
|
||||
const text = formatNumber(value, 0);
|
||||
return text && Number(value) > 0 ? `+${text}` : text;
|
||||
}
|
||||
function concepts(value: unknown): string { return Array.isArray(value) ? value.join(" · ") : ""; }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="page-header market-page-header">
|
||||
<div><h1>人气热榜</h1><p class="page-subtitle">双平台人气与共识 · 榜单日期 {{ data.trade_date }}</p></div>
|
||||
<span v-if="data.message" class="tag warning">{{ data.message }}</span>
|
||||
</header>
|
||||
<section class="popularity-summary-strip">
|
||||
<article><header><span>同花顺热度 Top3</span><strong>{{ data.summary.ths_count ?? 0 }}只</strong></header><p>{{ topThs.map((item) => item.name).join(' · ') || '暂无榜单' }}</p></article>
|
||||
<article><header><span>东方财富热度 Top3</span><strong>{{ data.summary.dc_count ?? 0 }}只</strong></header><p>{{ topDc.map((item) => item.name).join(' · ') || '暂无榜单' }}</p></article>
|
||||
<article><header><span>双榜共识</span><strong>{{ data.summary.dual_count ?? 0 }}只</strong></header><p>{{ consensus.map((item) => item.name).join(' · ') || '暂无共识' }}</p></article>
|
||||
</section>
|
||||
<section class="card popularity-table-card">
|
||||
<div class="popularity-toolbar">
|
||||
<div class="seg-control"><button type="button" :class="{ active: source === 'combined' }" @click="source = 'combined'">双榜综合</button><button type="button" :class="{ active: source === 'ths' }" @click="source = 'ths'">同花顺</button><button type="button" :class="{ active: source === 'dc' }" @click="source = 'dc'">东方财富</button></div>
|
||||
<label class="search-control"><span>搜索</span><input v-model="query" type="search" placeholder="代码、名称或题材"></label>
|
||||
</div>
|
||||
<DataTable v-if="rows.length" :columns="columns" :rows="rows" :sort-key="sortKey" :sort-direction="sortDirection" @sort="sort" />
|
||||
<EmptyState v-else title="暂无人气榜结果" description="当日榜单尚未生成时,系统会显示最近有效榜单并标注真实日期。" />
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,109 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
|
||||
import { marketApi, type MarketEntity, type MarketInsightData, type ThemeDetailData } from "../../../shared/api/market";
|
||||
import DataTable from "../../../shared/components/DataTable.vue";
|
||||
import EmptyState from "../../../shared/components/EmptyState.vue";
|
||||
import MarketPreviewPanel from "../../../shared/market/MarketPreviewPanel.vue";
|
||||
import { formatAmount, formatNumber, sortRows, type SortDirection } from "../../../shared/market/table";
|
||||
|
||||
const props = defineProps<{ data: MarketInsightData }>();
|
||||
const query = ref("");
|
||||
const selectedCode = ref("");
|
||||
const detail = ref<ThemeDetailData | null>(null);
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
const preview = ref<MarketEntity | null>(null);
|
||||
const sortKey = ref("change");
|
||||
const sortDirection = ref<SortDirection>("desc");
|
||||
let requestSequence = 0;
|
||||
const themes = computed(() => {
|
||||
const normalized = query.value.trim().toLocaleLowerCase();
|
||||
return (props.data.items ?? []).filter((item) => !normalized || [item.code, item.name]
|
||||
.some((value) => String(value ?? "").toLocaleLowerCase().includes(normalized)));
|
||||
});
|
||||
const members = computed(() => sortRows(detail.value?.members ?? [], sortKey.value, sortDirection.value));
|
||||
const columns = [
|
||||
{ key: "code", label: "代码", code: true, sortable: true },
|
||||
{ key: "name", label: "股票", sortable: true },
|
||||
{ key: "change", label: "涨跌幅(%)", numeric: true, sortable: true, format: number },
|
||||
{ key: "close", label: "收盘价(元)", numeric: true, sortable: true, format: number },
|
||||
{ key: "amount", label: "成交额", numeric: true, sortable: true, format: amount },
|
||||
{ key: "quoted", label: "行情状态", sortable: true, format: quoteState },
|
||||
];
|
||||
|
||||
watch(() => props.data.trade_date, () => {
|
||||
const first = props.data.items?.[0];
|
||||
if (first) void selectTheme(first);
|
||||
}, { immediate: true });
|
||||
|
||||
async function selectTheme(theme: Record<string, unknown>): Promise<void> {
|
||||
const code = String(theme.code ?? "");
|
||||
if (!code) return;
|
||||
selectedCode.value = code;
|
||||
const sequence = ++requestSequence;
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const result = await marketApi.themeDetail(code, props.data.trade_date ?? props.data.requested_date);
|
||||
if (sequence === requestSequence) detail.value = result;
|
||||
} catch (reason) {
|
||||
if (sequence === requestSequence) {
|
||||
detail.value = null;
|
||||
error.value = reason instanceof Error ? reason.message : "题材成分股读取失败";
|
||||
}
|
||||
} finally {
|
||||
if (sequence === requestSequence) loading.value = false;
|
||||
}
|
||||
}
|
||||
function showPreview(theme: Record<string, unknown>): void {
|
||||
const identifier = String(theme.code ?? "");
|
||||
preview.value = identifier ? {
|
||||
entity_type: "theme",
|
||||
identifier,
|
||||
code: identifier.split(".")[0] ?? identifier,
|
||||
name: String(theme.name ?? ""),
|
||||
sector: null,
|
||||
} : null;
|
||||
}
|
||||
function sort(key: string): void {
|
||||
if (sortKey.value === key) sortDirection.value = sortDirection.value === "asc" ? "desc" : "asc";
|
||||
else { sortKey.value = key; sortDirection.value = "desc"; }
|
||||
}
|
||||
function number(value: unknown): string { return formatNumber(value, 2); }
|
||||
function amount(value: unknown): string { return formatAmount(value); }
|
||||
function quoteState(value: unknown): string { return value ? "正常交易" : "当日无行情"; }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="page-header market-page-header">
|
||||
<div><h1>题材库</h1><p class="page-subtitle">题材排行与成分行情 · 数据日期 {{ data.trade_date }}</p></div>
|
||||
<span v-if="data.message" class="tag warning">{{ data.message }}</span>
|
||||
</header>
|
||||
<section class="themes-layout">
|
||||
<article class="card theme-directory">
|
||||
<header class="card-header"><h2>题材排行</h2><span class="faint">{{ data.summary.theme_count ?? 0 }}个题材</span></header>
|
||||
<label class="search-control theme-search"><span>搜索</span><input v-model="query" type="search" placeholder="题材名称或代码"></label>
|
||||
<div class="theme-rank-list">
|
||||
<button v-for="(theme, index) in themes" :key="String(theme.code)" type="button" :class="{ active: selectedCode === theme.code }" @click="selectTheme(theme)" @mouseenter="showPreview(theme)" @mouseleave="preview = null">
|
||||
<b>{{ index + 1 }}</b><span><strong>{{ theme.name }}</strong><small>{{ theme.code }}</small></span><em :class="{ up: Number(theme.change) > 0, down: Number(theme.change) < 0 }">{{ theme.change === null ? '' : `${number(theme.change)}%` }}</em>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="preview" class="theme-preview-popover"><MarketPreviewPanel :entity="preview" /></div>
|
||||
</article>
|
||||
<article class="card theme-detail-card">
|
||||
<header class="card-header"><h2>{{ detail?.theme.name || "题材基础行情" }}</h2><span class="faint">{{ detail?.theme.code || "选择左侧题材" }}</span></header>
|
||||
<div v-if="detail" class="theme-summary-strip">
|
||||
<div><span>成分股</span><strong>{{ detail.summary.member_count ?? 0 }}</strong></div>
|
||||
<div><span>有行情</span><strong>{{ detail.summary.quoted_count ?? 0 }}</strong></div>
|
||||
<div><span>上涨</span><strong class="up">{{ detail.summary.up_count ?? 0 }}</strong></div>
|
||||
<div><span>下跌</span><strong class="down">{{ detail.summary.down_count ?? 0 }}</strong></div>
|
||||
<div><span>换手率</span><strong>{{ number(detail.summary.turnover_rate) }}%</strong></div>
|
||||
</div>
|
||||
<div v-if="loading" class="workspace-state">正在核验题材成分行情</div>
|
||||
<EmptyState v-else-if="error" title="题材成分暂不可用" :description="error" />
|
||||
<DataTable v-else-if="members.length" :columns="columns" :rows="members" :sort-key="sortKey" :sort-direction="sortDirection" @sort="sort" />
|
||||
<EmptyState v-else title="暂无题材成分数据" :description="detail?.message || '选择左侧题材后显示成分股。'" />
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
@@ -77,6 +77,41 @@ export type RotationMembersData = {
|
||||
items: Record<string, unknown>[];
|
||||
};
|
||||
|
||||
export type MarketInsightData = MarketWorkspaceData & {
|
||||
requested_date: string;
|
||||
previous_date?: string;
|
||||
phase?: "pending" | "observing" | "selection" | "finalized" | "archive";
|
||||
current_available?: boolean;
|
||||
coverage?: number;
|
||||
summary: Record<string, unknown>;
|
||||
expectations?: Record<string, number>;
|
||||
themes?: { carry: Record<string, unknown>[]; new_themes: Record<string, unknown>[] };
|
||||
amount_history?: Record<string, unknown>[];
|
||||
focus_rows?: Record<string, unknown>[];
|
||||
rows?: Record<string, unknown>[];
|
||||
one_price_rows?: Record<string, unknown>[];
|
||||
watchlist_rows?: Record<string, unknown>[];
|
||||
watchlist_ready?: boolean;
|
||||
combined?: Record<string, unknown>[];
|
||||
ths?: Record<string, unknown>[];
|
||||
dc?: Record<string, unknown>[];
|
||||
status?: string;
|
||||
traders?: Record<string, unknown>[];
|
||||
operations?: Record<string, unknown>[];
|
||||
unclassified_seats?: Record<string, unknown>[];
|
||||
profiles?: Record<string, unknown>[];
|
||||
};
|
||||
|
||||
export type ThemeDetailData = {
|
||||
trade_date: string;
|
||||
observed_at?: string;
|
||||
state?: string;
|
||||
message: string;
|
||||
theme: Record<string, unknown>;
|
||||
summary: Record<string, unknown>;
|
||||
members: Record<string, unknown>[];
|
||||
};
|
||||
|
||||
export const marketApi = {
|
||||
summary(date?: string): Promise<MarketSummary> {
|
||||
const query = date ? `?date=${encodeURIComponent(date)}` : "";
|
||||
@@ -103,4 +138,22 @@ export const marketApi = {
|
||||
`/market/rotation-members?sector=${encodeURIComponent(sector)}&date=${encodeURIComponent(date)}`,
|
||||
);
|
||||
},
|
||||
insight(key: string, date: string): Promise<MarketInsightData> {
|
||||
return api.get<MarketInsightData>(
|
||||
`/market/insights/${encodeURIComponent(key)}?date=${encodeURIComponent(date)}`,
|
||||
);
|
||||
},
|
||||
syncInsight(key: string, date: string): Promise<MarketInsightData> {
|
||||
return api.post<MarketInsightData>(
|
||||
`/market/insights/${encodeURIComponent(key)}/sync?date=${encodeURIComponent(date)}`,
|
||||
);
|
||||
},
|
||||
themeDetail(identifier: string, date: string): Promise<ThemeDetailData> {
|
||||
return api.get<ThemeDetailData>(
|
||||
`/market/themes/${encodeURIComponent(identifier)}?date=${encodeURIComponent(date)}`,
|
||||
);
|
||||
},
|
||||
saveSeatAlias(seat_name: string, alias_name: string): Promise<{ seat_name: string; alias_name: string }> {
|
||||
return api.put("/market/seat-aliases", { seat_name, alias_name });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
export type SortDirection = "asc" | "desc";
|
||||
|
||||
export function sortRows(
|
||||
rows: Record<string, unknown>[],
|
||||
key: string,
|
||||
direction: SortDirection,
|
||||
): Record<string, unknown>[] {
|
||||
const multiplier = direction === "asc" ? 1 : -1;
|
||||
return [...rows].sort((left, right) => compare(left[key], right[key]) * multiplier);
|
||||
}
|
||||
|
||||
export function formatNumber(value: unknown, digits = 2): string {
|
||||
if (value === null || value === undefined || value === "") return "";
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed.toFixed(digits) : "";
|
||||
}
|
||||
|
||||
export function formatAmount(value: unknown): string {
|
||||
if (value === null || value === undefined || value === "") return "";
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) return "";
|
||||
return parsed >= 100_000_000
|
||||
? `${(parsed / 100_000_000).toFixed(2)} 亿`
|
||||
: `${(parsed / 10_000).toFixed(2)} 万`;
|
||||
}
|
||||
|
||||
export function exportCsv(filename: string, rows: string[][]): void {
|
||||
const content = rows
|
||||
.map((row) => row.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 = filename;
|
||||
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");
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
.search-control {
|
||||
min-height: var(--s-32);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-8);
|
||||
padding: 0 var(--s-10);
|
||||
border: var(--s-1) solid var(--color-border);
|
||||
border-radius: var(--control-radius);
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-surface);
|
||||
font-size: var(--font-11);
|
||||
}
|
||||
|
||||
.search-control input {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
border: 0;
|
||||
color: var(--color-text);
|
||||
background: var(--c-transparent);
|
||||
}
|
||||
|
||||
.search-control input::placeholder { color: var(--color-text-faint); }
|
||||
|
||||
.auction-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) var(--s-360);
|
||||
gap: var(--layout-gap);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.auction-main,
|
||||
.popularity-table-card,
|
||||
.theme-detail-card,
|
||||
.dragon-operations {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.auction-dataset-bar {
|
||||
min-height: var(--s-46);
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
border-bottom: var(--s-1) solid var(--color-divider);
|
||||
}
|
||||
|
||||
.auction-datasets {
|
||||
align-self: center;
|
||||
margin-left: var(--s-10);
|
||||
}
|
||||
|
||||
.auction-datasets b { margin-left: var(--s-4); font-variant-numeric: tabular-nums; }
|
||||
|
||||
.auction-inline-summary {
|
||||
min-width: var(--s-360);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
margin: 0 0 0 auto;
|
||||
border-left: var(--s-1) solid var(--color-divider);
|
||||
}
|
||||
|
||||
.auction-inline-summary div {
|
||||
display: grid;
|
||||
align-content: center;
|
||||
gap: var(--s-2);
|
||||
padding: var(--s-6) var(--s-8);
|
||||
border-right: var(--s-1) solid var(--color-divider);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.auction-inline-summary div:last-child { border-right: 0; }
|
||||
.auction-inline-summary dt { color: var(--color-text-faint); font-size: var(--font-10-5); }
|
||||
.auction-inline-summary dd { margin: 0; font-size: var(--font-12); font-weight: var(--weight-700); font-variant-numeric: tabular-nums; }
|
||||
|
||||
.auction-toolbar,
|
||||
.popularity-toolbar,
|
||||
.dragon-toolbar {
|
||||
min-height: var(--s-46);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-8);
|
||||
padding: var(--s-7) var(--s-10);
|
||||
border-bottom: var(--s-1) solid var(--color-divider);
|
||||
}
|
||||
|
||||
.auction-toolbar .seg-control,
|
||||
.popularity-toolbar .seg-control,
|
||||
.dragon-toolbar .seg-control { margin-left: 0; }
|
||||
.auction-toolbar .search-control,
|
||||
.popularity-toolbar .search-control,
|
||||
.dragon-toolbar .search-control { width: var(--s-260); margin-left: auto; }
|
||||
|
||||
.auction-side { display: grid; gap: var(--layout-gap); }
|
||||
.auction-evidence-list { display: grid; gap: var(--s-1); padding: var(--s-6) var(--s-14) var(--s-10); }
|
||||
.auction-evidence-list > div { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: var(--s-2) var(--s-8); padding: var(--s-7) 0; border-bottom: var(--s-1) solid var(--color-divider); }
|
||||
.auction-evidence-list > div:last-of-type { border-bottom: 0; }
|
||||
.auction-evidence-list strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.auction-evidence-list span { color: var(--color-primary); font-size: var(--font-11); }
|
||||
.auction-evidence-list small { grid-column: 1 / -1; color: var(--color-text-secondary); font-size: var(--font-10-5); }
|
||||
.auction-evidence-list > p { padding: var(--s-14) 0; text-align: center; }
|
||||
|
||||
.auction-bars { display: grid; gap: var(--s-8); padding: var(--s-12) var(--s-14); }
|
||||
.auction-bars > div { display: grid; grid-template-columns: var(--s-44) minmax(0, 1fr) var(--s-44); align-items: center; gap: var(--s-8); font-size: var(--font-10-5); }
|
||||
.auction-bars i { height: var(--s-7); overflow: hidden; border-radius: var(--radius-round); background: var(--color-surface-muted); }
|
||||
.auction-bars b { display: block; height: 100%; border-radius: inherit; background: var(--color-primary); }
|
||||
.auction-bars strong { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
|
||||
.themes-layout {
|
||||
display: grid;
|
||||
grid-template-columns: var(--s-320) minmax(0, 1fr);
|
||||
gap: var(--layout-gap);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.theme-directory { position: relative; min-width: 0; }
|
||||
.theme-search { margin: var(--s-10); }
|
||||
.theme-rank-list { max-height: calc(100vh - var(--s-260)); overflow-y: auto; border-top: var(--s-1) solid var(--color-divider); }
|
||||
.theme-rank-list > button { width: 100%; display: grid; grid-template-columns: var(--s-28) minmax(0, 1fr) var(--s-56); align-items: center; gap: var(--s-8); padding: var(--s-8) var(--s-10); border-bottom: var(--s-1) solid var(--color-divider); background: var(--color-surface); text-align: left; }
|
||||
.theme-rank-list > button:hover,
|
||||
.theme-rank-list > button.active { background: var(--color-primary-soft); }
|
||||
.theme-rank-list > button > b { color: var(--color-text-faint); text-align: center; font-variant-numeric: tabular-nums; }
|
||||
.theme-rank-list > button:nth-child(-n+3) > b { color: var(--color-warning); font-size: var(--font-14); }
|
||||
.theme-rank-list > button > span { min-width: 0; display: grid; gap: var(--s-2); }
|
||||
.theme-rank-list strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.theme-rank-list small { color: var(--color-text-faint); font-size: var(--font-10-5); }
|
||||
.theme-rank-list em { font-style: normal; text-align: right; font-size: var(--font-11); font-variant-numeric: tabular-nums; }
|
||||
.theme-preview-popover { position: absolute; top: var(--s-64); left: calc(100% + var(--layout-gap)); z-index: var(--z-popover); width: var(--s-400); overflow: hidden; border: var(--s-1) solid var(--color-border); border-radius: var(--card-radius); background: var(--color-surface-raised); box-shadow: var(--shadow-float); }
|
||||
.theme-summary-strip { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); border-bottom: var(--s-1) solid var(--color-divider); }
|
||||
.theme-summary-strip > div { display: grid; gap: var(--s-4); padding: var(--s-10) var(--s-14); border-right: var(--s-1) solid var(--color-divider); }
|
||||
.theme-summary-strip > div:last-child { border-right: 0; }
|
||||
.theme-summary-strip span { color: var(--color-text-secondary); font-size: var(--font-11); }
|
||||
.theme-summary-strip strong { font-size: var(--font-15); font-variant-numeric: tabular-nums; }
|
||||
|
||||
.popularity-summary-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
overflow: hidden;
|
||||
border: var(--s-1) solid var(--color-border);
|
||||
border-radius: var(--card-radius);
|
||||
background: var(--color-canvas);
|
||||
}
|
||||
|
||||
.popularity-summary-strip article { min-width: 0; display: grid; gap: var(--s-8); padding: var(--s-12) var(--s-14); border-right: var(--s-1) solid var(--color-border); box-shadow: none; }
|
||||
.popularity-summary-strip article:nth-child(1) { background: var(--color-primary-soft); }
|
||||
.popularity-summary-strip article:nth-child(2) { background: var(--color-up-soft); }
|
||||
.popularity-summary-strip article:nth-child(3) { border-right: 0; background: var(--color-warning-soft); }
|
||||
.popularity-summary-strip header { display: flex; justify-content: space-between; gap: var(--s-8); }
|
||||
.popularity-summary-strip header span { color: var(--color-text-secondary); }
|
||||
.popularity-summary-strip header strong { font-variant-numeric: tabular-nums; }
|
||||
.popularity-summary-strip p { overflow: hidden; color: var(--color-text); font-size: var(--font-12); text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.dragon-summary-grid { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: var(--layout-gap); }
|
||||
.dragon-summary-grid article { display: flex; align-items: baseline; justify-content: space-between; gap: var(--s-8); padding: var(--s-12) var(--s-14); }
|
||||
.dragon-summary-grid span { color: var(--color-text-secondary); font-size: var(--font-11); }
|
||||
.dragon-summary-grid strong { font-size: var(--font-15); font-variant-numeric: tabular-nums; }
|
||||
.dragon-daily-layout { display: grid; grid-template-columns: var(--s-320) minmax(0, 1fr); gap: var(--layout-gap); align-items: start; }
|
||||
.dragon-traders { min-width: 0; overflow: hidden; }
|
||||
.dragon-traders .card-header .btn { margin-left: auto; }
|
||||
.dragon-trader-list { display: grid; }
|
||||
.dragon-trader-list > button { min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: var(--s-8); padding: var(--s-9) var(--s-12); border-bottom: var(--s-1) solid var(--color-divider); background: var(--color-surface); text-align: left; }
|
||||
.dragon-trader-list > button:hover,
|
||||
.dragon-trader-list > button.active { background: var(--color-primary-soft); }
|
||||
.dragon-trader-list > button span { min-width: 0; display: grid; gap: var(--s-2); }
|
||||
.dragon-trader-list small { overflow: hidden; color: var(--color-text-secondary); font-size: var(--font-10-5); text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dragon-trader-list b { align-self: center; font-variant-numeric: tabular-nums; }
|
||||
.dragon-trader-list > p { padding: var(--s-20); text-align: center; }
|
||||
.dragon-pending { border-top: var(--s-1) solid var(--color-divider); }
|
||||
.dragon-pending > header { display: flex; justify-content: space-between; padding: var(--s-10) var(--s-12); background: var(--color-warning-soft); }
|
||||
.dragon-pending > div { display: grid; gap: var(--s-6); padding: var(--s-8) var(--s-12); border-top: var(--s-1) solid var(--color-divider); }
|
||||
.dragon-pending p,
|
||||
.dragon-pending form { display: flex; align-items: center; gap: var(--s-8); }
|
||||
.dragon-pending p span { min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dragon-pending input { min-width: 0; flex: 1; min-height: var(--s-32); padding: 0 var(--s-8); border: var(--s-1) solid var(--color-border); border-radius: var(--control-radius); background: var(--color-surface); }
|
||||
.dragon-table-scroll { height: var(--s-400); overflow: auto; }
|
||||
.dragon-table-scroll .data-table-wrap { overflow: visible; }
|
||||
.dragon-profiles { min-height: var(--s-400); display: grid; grid-template-columns: var(--s-320) minmax(0, 1fr); overflow: hidden; }
|
||||
.dragon-profiles > aside { border-right: var(--s-1) solid var(--color-divider); }
|
||||
.dragon-profiles > aside > div { max-height: var(--s-400); overflow-y: auto; }
|
||||
.dragon-profiles > aside button { width: 100%; display: grid; gap: var(--s-2); padding: var(--s-9) var(--s-14); border-bottom: var(--s-1) solid var(--color-divider); background: var(--color-surface); text-align: left; }
|
||||
.dragon-profiles > aside button.active,
|
||||
.dragon-profiles > aside button:hover { background: var(--color-primary-soft); }
|
||||
.dragon-profiles > aside small { color: var(--color-text-secondary); font-size: var(--font-10-5); }
|
||||
.dragon-profiles > article { display: grid; align-content: start; gap: var(--s-20); padding: var(--s-20); }
|
||||
.dragon-profiles > article > header { display: flex; justify-content: space-between; gap: var(--s-14); }
|
||||
.dragon-profiles > article > header p { margin-top: var(--s-8); color: var(--color-text-secondary); line-height: var(--s-20); }
|
||||
.dragon-profiles h3 { margin: 0 0 var(--s-8); font-size: var(--font-13); }
|
||||
.dragon-profiles ul { display: flex; flex-wrap: wrap; gap: var(--s-8); margin: 0; padding: 0; list-style: none; }
|
||||
.dragon-profiles li { padding: var(--s-6) var(--s-8); border: var(--s-1) solid var(--color-border); border-radius: var(--control-radius); background: var(--color-surface-muted); font-size: var(--font-11); }
|
||||
@@ -245,3 +245,92 @@
|
||||
border-bottom: var(--s-1) solid var(--color-divider);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1399px) {
|
||||
.auction-layout {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.auction-side {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.auction-dataset-bar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.auction-datasets {
|
||||
align-self: stretch;
|
||||
overflow-x: auto;
|
||||
margin: var(--s-7) var(--s-10);
|
||||
}
|
||||
|
||||
.auction-inline-summary {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
margin-left: 0;
|
||||
border-top: var(--s-1) solid var(--color-divider);
|
||||
border-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.auction-side,
|
||||
.themes-layout,
|
||||
.popularity-summary-strip,
|
||||
.dragon-summary-grid,
|
||||
.dragon-daily-layout,
|
||||
.dragon-profiles {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.auction-toolbar,
|
||||
.popularity-toolbar,
|
||||
.dragon-toolbar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.auction-toolbar .seg-control,
|
||||
.popularity-toolbar .seg-control,
|
||||
.dragon-toolbar .seg-control {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.auction-toolbar .search-control,
|
||||
.popularity-toolbar .search-control,
|
||||
.dragon-toolbar .search-control {
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.auction-inline-summary,
|
||||
.theme-summary-strip {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.theme-rank-list {
|
||||
max-height: var(--s-320);
|
||||
}
|
||||
|
||||
.theme-preview-popover {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.popularity-summary-strip article,
|
||||
.dragon-profiles > aside {
|
||||
border-right: 0;
|
||||
border-bottom: var(--s-1) solid var(--color-divider);
|
||||
}
|
||||
|
||||
.dragon-summary-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.dragon-table-scroll {
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user