| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384 |
- <script setup lang="ts">
- import { computed, onMounted, ref } from 'vue'
- import IcicleHeatTree from '../components/IcicleHeatTree.vue'
- import {
- fetchGrowthCategoryTopContents,
- fetchGrowthCategoryTree,
- type GrowthChannelContentRankItem,
- } from '../api/growthCategory'
- import type { CategoryNode, WeightDimKey, WeightDimMeta } from '../types/category'
- import type { HeatTabKey } from '../types/heatTree'
- const GROWTH_HEAT_TABS: WeightDimMeta[] = [
- { key: 'read_rate', label: '阅读率' },
- { key: 'avg_read_rate', label: '平均阅读率' },
- { key: 'like_rate', label: '点赞率' },
- ]
- const nodes = ref<CategoryNode[]>([])
- const bizDt = ref<string | null>(null)
- const loading = ref(true)
- const error = ref<string | null>(null)
- const selectedNode = ref<{ id: number; name: string } | null>(null)
- const activeMetric = ref<WeightDimKey>('read_rate')
- const rankItems = ref<GrowthChannelContentRankItem[]>([])
- const rankLoading = ref(false)
- const rankError = ref<string | null>(null)
- const rankPanelCollapsed = ref(false)
- let rankRequestId = 0
- const activeMetricLabel = computed(
- () =>
- GROWTH_HEAT_TABS.find((tab) => tab.key === activeMetric.value)?.label ??
- activeMetric.value,
- )
- onMounted(async () => {
- try {
- const tree = await fetchGrowthCategoryTree()
- nodes.value = tree.nodes ?? []
- bizDt.value = tree.biz_dt ?? null
- } catch (cause) {
- error.value = cause instanceof Error ? cause.message : String(cause)
- } finally {
- loading.value = false
- }
- })
- async function loadRankings() {
- if (!selectedNode.value || !bizDt.value) return
- const requestId = ++rankRequestId
- rankLoading.value = true
- rankError.value = null
- try {
- const response = await fetchGrowthCategoryTopContents(
- selectedNode.value.id,
- bizDt.value,
- activeMetric.value,
- )
- if (requestId === rankRequestId) rankItems.value = response.items ?? []
- } catch (cause) {
- if (requestId === rankRequestId) {
- rankItems.value = []
- rankError.value = cause instanceof Error ? cause.message : String(cause)
- }
- } finally {
- if (requestId === rankRequestId) rankLoading.value = false
- }
- }
- function onNodeSelected(node: { id: number; name: string }) {
- selectedNode.value = node
- void loadRankings()
- }
- function onTabChange(tab: HeatTabKey) {
- if (tab === 'full') return
- activeMetric.value = tab
- if (selectedNode.value) void loadRankings()
- }
- function formatScore(value: number): string {
- if (!Number.isFinite(value)) return '—'
- if (Math.abs(value) >= 100) return value.toFixed(2)
- if (Math.abs(value) >= 1) return value.toFixed(4)
- return value.toFixed(6)
- }
- function formatContribution(value: number): string {
- return Number.isFinite(value) ? value.toFixed(2) : '—'
- }
- function openArticle(channelContentId: string) {
- const target = `/api/growth-channel-content/${encodeURIComponent(channelContentId)}/view`
- window.open(target, '_blank', 'noopener,noreferrer')
- }
- </script>
- <template>
- <div class="view">
- <div v-if="loading" class="state">正在加载增长分类树与权重…</div>
- <div v-else-if="error" class="state error">{{ error }}</div>
- <div
- v-else
- class="content-layout"
- :class="{ 'rank-panel-collapsed': rankPanelCollapsed }"
- >
- <IcicleHeatTree
- class="heat-map"
- :nodes="nodes"
- :biz-dt="bizDt"
- title="增长全局热度地图"
- subtitle="global_category_v2 分类树 · 横向为层级,纵向为分支规模"
- :heat-tabs="GROWTH_HEAT_TABS"
- initial-tab="read_rate"
- heat-only
- @node-selected="onNodeSelected"
- @tab-change="onTabChange"
- />
- <aside class="rank-panel" :class="{ collapsed: rankPanelCollapsed }">
- <header class="rank-header" :class="{ collapsed: rankPanelCollapsed }">
- <button
- type="button"
- class="rank-collapse-button"
- :title="rankPanelCollapsed ? '展开右侧详情' : '收起右侧详情'"
- :aria-label="rankPanelCollapsed ? '展开右侧详情' : '收起右侧详情'"
- :aria-expanded="!rankPanelCollapsed"
- @click="rankPanelCollapsed = !rankPanelCollapsed"
- >
- {{ rankPanelCollapsed ? '‹' : '›' }}
- </button>
- <div v-if="!rankPanelCollapsed" class="rank-header-copy">
- <span class="rank-eyebrow">CHANNEL CONTENT TOP 10</span>
- <h2>{{ selectedNode?.name || '请选择分类节点' }}</h2>
- <p v-if="selectedNode">
- {{ activeMetricLabel }}贡献权重 · stable_id {{ selectedNode.id }}
- </p>
- <p v-else>点击左侧热力图节点查看当前维度的文章榜单</p>
- </div>
- </header>
- <template v-if="!rankPanelCollapsed">
- <div v-if="rankLoading" class="rank-state">正在加载 Top 10…</div>
- <div v-else-if="rankError" class="rank-state error">{{ rankError }}</div>
- <div v-else-if="!selectedNode" class="rank-state">尚未选择节点</div>
- <div v-else-if="!rankItems.length" class="rank-state">该节点当前维度暂无文章</div>
- <ol v-else class="rank-list">
- <li v-for="item in rankItems" :key="item.channel_content_id">
- <button type="button" @click="openArticle(item.channel_content_id)">
- <span class="rank-no">{{ item.rank_no }}</span>
- <span class="rank-main">
- <code>{{ item.channel_content_id }}</code>
- <small>
- source_element {{ item.source_element_id }}
- · contribution {{ formatContribution(item.contribution) }}
- </small>
- </span>
- <span class="rank-score">
- <strong>{{ formatScore(item.weighted_score) }}</strong>
- <small>{{ activeMetricLabel }}贡献权重分</small>
- </span>
- </button>
- </li>
- </ol>
- </template>
- </aside>
- </div>
- </div>
- </template>
- <style scoped>
- .view {
- height: max(760px, calc(100vh - 106px));
- min-height: 760px;
- padding-bottom: 8px;
- }
- .content-layout {
- display: grid;
- grid-template-columns: minmax(0, 1fr) 360px;
- gap: 12px;
- height: 100%;
- min-height: 0;
- }
- .content-layout.rank-panel-collapsed {
- grid-template-columns: minmax(0, 1fr) 44px;
- }
- .heat-map {
- min-width: 0;
- }
- .rank-panel {
- display: flex;
- min-width: 0;
- min-height: 0;
- flex-direction: column;
- overflow: hidden;
- border: 1px solid #e2e8f0;
- border-radius: 10px;
- background: #fff;
- }
- .rank-panel.collapsed {
- background: #f8fafc;
- }
- .rank-header {
- flex-shrink: 0;
- padding: 16px;
- border-bottom: 1px solid #e8edf2;
- background: #f8fafc;
- }
- .rank-header.collapsed {
- display: flex;
- height: 100%;
- justify-content: center;
- padding: 8px 5px;
- border-bottom: 0;
- }
- .rank-header-copy {
- min-width: 0;
- }
- .rank-collapse-button {
- float: right;
- width: 28px;
- height: 28px;
- margin: 0 0 6px 8px;
- border: 1px solid #cbd5e1;
- border-radius: 7px;
- background: #fff;
- color: #475569;
- font-size: 18px;
- line-height: 1;
- cursor: pointer;
- }
- .rank-collapse-button:hover {
- border-color: #818cf8;
- color: #4338ca;
- }
- .rank-header.collapsed .rank-collapse-button {
- float: none;
- margin: 0;
- }
- .rank-eyebrow {
- color: #6366f1;
- font-size: 10px;
- font-weight: 700;
- letter-spacing: 0.1em;
- }
- .rank-header h2 {
- margin: 7px 0 4px;
- color: #0f172a;
- font-size: 17px;
- }
- .rank-header p {
- margin: 0;
- color: #64748b;
- font-size: 12px;
- }
- .rank-state {
- display: grid;
- flex: 1;
- place-items: center;
- padding: 24px;
- color: #94a3b8;
- font-size: 13px;
- text-align: center;
- }
- .rank-state.error {
- color: #b91c1c;
- }
- .rank-list {
- margin: 0;
- padding: 8px;
- overflow-y: auto;
- list-style: none;
- }
- .rank-list li + li {
- border-top: 1px solid #eef2f7;
- }
- .rank-list button {
- display: grid;
- width: 100%;
- padding: 12px 8px;
- grid-template-columns: 28px minmax(0, 1fr) auto;
- gap: 8px;
- align-items: center;
- border: 0;
- border-radius: 7px;
- background: transparent;
- color: inherit;
- cursor: pointer;
- text-align: left;
- }
- .rank-list button:hover {
- background: #f1f5f9;
- }
- .rank-no {
- display: grid;
- width: 24px;
- height: 24px;
- place-items: center;
- border-radius: 7px;
- background: #e0e7ff;
- color: #4338ca;
- font-size: 12px;
- font-weight: 700;
- }
- .rank-main,
- .rank-score {
- display: flex;
- min-width: 0;
- flex-direction: column;
- gap: 4px;
- }
- .rank-main code {
- overflow: hidden;
- color: #334155;
- font-size: 11px;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- .rank-main small,
- .rank-score small {
- color: #94a3b8;
- font-size: 10px;
- }
- .rank-score {
- align-items: flex-end;
- }
- .rank-score strong {
- color: #dc2626;
- font-size: 13px;
- font-variant-numeric: tabular-nums;
- }
- @media (max-width: 1100px) {
- .view {
- height: auto;
- }
- .content-layout {
- grid-template-columns: 1fr;
- grid-template-rows: 760px minmax(360px, auto);
- }
- .rank-panel {
- min-height: 360px;
- }
- }
- .state {
- padding: 64px 24px;
- color: #64748b;
- font-size: 15px;
- text-align: center;
- }
- .state.error {
- color: #b91c1c;
- }
- </style>
|