feat: Wave 2 - API org isolation + factory.html + insights.html

- Add org_id auto-population to articles.py, publishing.py, metrics.py
- Add org_id filtering to search_rankings.py geo endpoints
- Add org_id to calendar.py create/update/delete endpoints
- Add org_id to tasks.py create endpoints
- Create factory.html content pipeline page (3-tab: 待创作/进行中/待审查)
- Create insights.html analytics page (4-tab: 概览/搜索排名/GEO/平台对比)
This commit is contained in:
Yuzhiran Dev
2026-06-17 16:13:48 +08:00
parent dcfeccc97b
commit 5caf7bc52e
11 changed files with 2091 additions and 37 deletions
+945
View File
@@ -0,0 +1,945 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>宇之然内容创作平台 - 数据洞察</title>
<link rel="stylesheet" href="element-plus.css">
<link rel="stylesheet" href="theme-modern.css">
<style>
/* ===== Insight-specific overrides ===== */
.insight-hero {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: var(--radius-lg);
padding: 28px 32px;
color: white;
margin-bottom: var(--spacing-lg);
position: relative;
overflow: hidden;
}
.insight-hero::after {
content: '';
position: absolute;
top: -40%;
right: -10%;
width: 300px;
height: 300px;
border-radius: 50%;
background: rgba(255,255,255,0.08);
}
.insight-hero::before {
content: '';
position: absolute;
bottom: -60%;
left: -5%;
width: 250px;
height: 250px;
border-radius: 50%;
background: rgba(255,255,255,0.05);
}
.insight-hero h2 {
font-size: 22px;
font-weight: 700;
margin-bottom: 6px;
position: relative;
z-index: 1;
}
.insight-hero p {
font-size: 13px;
opacity: 0.85;
position: relative;
z-index: 1;
}
.insight-summary {
display: flex;
gap: 24px;
flex-wrap: wrap;
margin-top: 20px;
position: relative;
z-index: 1;
}
.insight-summary-item {
display: flex;
flex-direction: column;
min-width: 100px;
}
.insight-summary-item .val {
font-size: 28px;
font-weight: 800;
line-height: 1.1;
}
.insight-summary-item .lbl {
font-size: 12px;
opacity: 0.8;
margin-top: 2px;
}
/* Score bar */
.score-bar-wrap {
width: 100%;
height: 10px;
background: #ebeef5;
border-radius: 5px;
overflow: hidden;
margin-top: 6px;
}
.score-bar {
height: 100%;
border-radius: 5px;
transition: width 0.6s var(--ease-out);
}
.score-bar.green { background: linear-gradient(90deg, #67c23a, #85ce61); }
.score-bar.yellow { background: linear-gradient(90deg, #e6a23c, #f5c543); }
.score-bar.red { background: linear-gradient(90deg, #f56c6c, #f78989); }
/* GEO factor checklist */
.geo-factor-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 10px;
margin-top: 10px;
}
.geo-factor-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: var(--color-bg);
border-radius: var(--radius-sm);
font-size: 13px;
}
.geo-factor-item .check {
width: 18px;
height: 18px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
flex-shrink: 0;
}
.geo-factor-item .check.pass { background: #67c23a; color: white; }
.geo-factor-item .check.fail { background: #f56c6c; color: white; }
/* Platform comparison bars */
.platform-bar-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 14px;
}
.platform-bar-label {
width: 80px;
font-size: 13px;
font-weight: 600;
color: var(--color-text-regular);
flex-shrink: 0;
text-align: right;
}
.platform-bar-track {
flex: 1;
height: 26px;
background: #f0f5ff;
border-radius: 6px;
overflow: hidden;
position: relative;
}
.platform-bar-fill {
height: 100%;
border-radius: 6px;
transition: width 0.8s var(--ease-out);
display: flex;
align-items: center;
padding-left: 10px;
font-size: 12px;
font-weight: 600;
color: white;
white-space: nowrap;
}
.platform-bar-fill.zhihu { background: linear-gradient(90deg, #667eea, #764ba2); }
.platform-bar-fill.wechat { background: linear-gradient(90deg, #07c160, #30b052); }
.platform-bar-fill.xiaohongshu { background: linear-gradient(90deg, #ff2442, #ff6b81); }
.platform-bar-value {
width: 70px;
font-size: 13px;
font-weight: 600;
color: var(--color-text-primary);
flex-shrink: 0;
}
/* Engagement bar */
.engagement-bar-wrap {
width: 60px;
height: 6px;
background: #ebeef5;
border-radius: 3px;
overflow: hidden;
display: inline-block;
vertical-align: middle;
margin-right: 4px;
}
.engagement-bar-fill {
height: 100%;
border-radius: 3px;
}
/* Position badge */
.pos-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 36px;
padding: 2px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 700;
}
.pos-badge.top10 { background: #f0f9ff; color: #67c23a; }
.pos-badge.top30 { background: #fdf6ec; color: #e6a23c; }
.pos-badge.low30 { background: #fef0f0; color: #f56c6c; }
/* Tab styling */
.el-tabs__header { margin-bottom: 0; }
.el-tabs__nav-scroll { padding-left: 0; }
/* Sub-score mini card */
.subscore-row {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-top: 8px;
}
.subscore-chip {
padding: 4px 10px;
border-radius: 6px;
font-size: 12px;
background: var(--color-bg);
border: 1px solid var(--color-border);
color: var(--color-text-regular);
}
.subscore-chip strong {
color: var(--color-text-primary);
font-weight: 700;
}
@media (max-width: 768px) {
.insight-hero { padding: 20px; }
.insight-hero h2 { font-size: 18px; }
.insight-summary-item .val { font-size: 22px; }
.platform-bar-label { width: 60px; font-size: 12px; }
.platform-bar-value { width: 50px; font-size: 12px; }
}
</style>
<script src="uni-nav.js"></script>
</head>
<body>
<div id="app" v-cloak>
<uni-nav title="数据洞察" :username="currentUser.username" :is-admin="isAdmin" current-page="insights" @navigate="redirectToPage" @logout="handleLogout">
</uni-nav>
<div class="main-content">
<main class="content-area">
<!-- Hero Header -->
<div class="insight-hero page-fade">
<h2><el-icon style="vertical-align:-2px;"><IconData /></el-icon> 数据洞察</h2>
<p>内容表现 · 搜索排名 · GEO 就绪度 · 平台对比</p>
<div class="insight-summary">
<div class="insight-summary-item">
<span class="val">{{ summary.total_published }}</span>
<span class="lbl">已发布</span>
</div>
<div class="insight-summary-item">
<span class="val">{{ summary.total_views }}</span>
<span class="lbl">总阅读</span>
</div>
<div class="insight-summary-item">
<span class="val">{{ summary.total_likes }}</span>
<span class="lbl">总点赞</span>
</div>
<div class="insight-summary-item">
<span class="val">{{ summary.avg_engagement }}%</span>
<span class="lbl">平均互动率</span>
</div>
</div>
</div>
<!-- Tabs -->
<el-tabs v-model="activeTab" type="border-card" style="background:transparent;box-shadow:none;">
<!-- Tab 1: 内容概览 -->
<el-tab-pane label="内容概览" name="overview">
<div class="page-fade">
<!-- Summary cards -->
<el-row :gutter="16" style="margin-bottom:20px;">
<el-col :xs="12" :sm="8" :md="6" v-for="(item, key) in overviewCards" :key="key">
<el-card shadow="hover" style="margin-bottom:12px;text-align:center;border-radius:var(--radius-md);">
<div style="font-size:28px;font-weight:800;color:{{ item.color }};">{{ item.value }}</div>
<div style="font-size:12px;color:var(--color-text-secondary);margin-top:4px;">{{ item.label }}</div>
</el-card>
</el-col>
</el-row>
<!-- Top performing topics -->
<el-card shadow="never" style="border-radius:var(--radius-md);margin-bottom:20px;">
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center;">
<span style="font-weight:600;font-size:15px;">
<el-icon style="vertical-align:-2px;"><IconStar /></el-icon> 热门选题 TOP10
</span>
</div>
</template>
<div v-if="topTopics.length === 0" style="text-align:center;padding:30px;color:var(--color-text-secondary);">
<el-icon style="font-size:40px;margin-bottom:8px;display:block;"><IconData /></el-icon>
暂无数据
</div>
<div v-else>
<div class="card-table">
<el-table :data="topTopics" stripe size="small">
<el-table-column type="index" label="#" width="40" align="center" />
<el-table-column prop="title" label="标题" min-width="200" show-overflow-tooltip />
<el-table-column label="阅读" width="100" align="right">
<template #default="scope">{{ formatNum(scope.row.total_views) }}</template>
</el-table-column>
<el-table-column label="点赞" width="100" align="right">
<template #default="scope">{{ formatNum(scope.row.total_likes) }}</template>
</el-table-column>
<el-table-column label="互动率" width="100" align="center">
<template #default="scope">
<div style="display:flex;align-items:center;justify-content:center;gap:4px;">
<div class="engagement-bar-wrap">
<div class="engagement-bar-fill" :style="{width: Math.min(scope.row.engagement_pct || 0, 100)+'%', background: engagementColor(scope.row.engagement_pct)}"></div>
</div>
<span>{{ (scope.row.engagement_pct || 0).toFixed(1) }}%</span>
</div>
</template>
</el-table-column>
</el-table>
</div>
<div class="card-list-mobile">
<div v-for="(t, i) in topTopics" :key="t.topic_id" class="card-list-mobile card-item" style="background:var(--color-bg);border-radius:10px;padding:14px;margin-bottom:10px;border:1px solid var(--color-border);">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
<span style="font-weight:700;color:var(--color-primary);">#{{ i+1 }}</span>
<span style="font-size:13px;color:var(--color-text-secondary);">{{ formatNum(t.total_views) }} 阅读</span>
</div>
<div style="font-weight:600;font-size:14px;margin-bottom:8px;">{{ t.title }}</div>
<div style="display:flex;justify-content:space-between;font-size:12px;color:var(--color-text-regular);">
<span>点赞 {{ formatNum(t.total_likes) }}</span>
<span>互动率 {{ (t.engagement_pct||0).toFixed(1) }}%</span>
</div>
</div>
</div>
</div>
</el-card>
<!-- Recent metrics -->
<el-card shadow="never" style="border-radius:var(--radius-md);">
<template #header>
<span style="font-weight:600;font-size:15px;">
<el-icon style="vertical-align:-2px;"><IconHistory /></el-icon> 最近数据
</span>
</template>
<div class="card-table">
<el-table :data="recentMetrics" stripe size="small">
<el-table-column label="选题" min-width="160" show-overflow-tooltip>
<template #default="scope">{{ scope.row.topic_id }}</template>
</el-table-column>
<el-table-column label="平台" width="100">
<template #default="scope">{{ platformName(scope.row.platform) }}</template>
</el-table-column>
<el-table-column label="阅读" width="80" align="right">
<template #default="scope">{{ formatNum(scope.row.views) }}</template>
</el-table-column>
<el-table-column label="点赞" width="80" align="right">
<template #default="scope">{{ formatNum(scope.row.likes) }}</template>
</el-table-column>
<el-table-column label="评论" width="80" align="right">
<template #default="scope">{{ formatNum(scope.row.comments) }}</template>
</el-table-column>
<el-table-column label="分享" width="80" align="right">
<template #default="scope">{{ formatNum(scope.row.shares) }}</template>
</el-table-column>
<el-table-column label="互动率" width="90" align="center">
<template #default="scope">{{ (scope.row.engagement_rate||0).toFixed(1) }}%</template>
</el-table-column>
<el-table-column label="更新时间" width="150">
<template #default="scope">{{ formatDate(scope.row.last_fetched) }}</template>
</el-table-column>
</el-table>
</div>
<div class="card-list-mobile">
<div v-for="m in recentMetrics" :key="m.id" class="card-item" style="background:var(--color-bg);border-radius:10px;padding:14px;margin-bottom:10px;border:1px solid var(--color-border);">
<div style="font-weight:600;font-size:13px;margin-bottom:8px;">{{ m.topic_id }} · {{ platformName(m.platform) }}</div>
<div style="display:flex;justify-content:space-between;font-size:12px;color:var(--color-text-regular);">
<span>阅读 {{ formatNum(m.views) }}</span>
<span>点赞 {{ formatNum(m.likes) }}</span>
<span>评论 {{ formatNum(m.comments) }}</span>
</div>
<div style="display:flex;justify-content:space-between;font-size:12px;color:var(--color-text-regular);margin-top:4px;">
<span>分享 {{ formatNum(m.shares) }}</span>
<span>互动率 {{ (m.engagement_rate||0).toFixed(1) }}%</span>
<span>{{ formatDate(m.last_fetched) }}</span>
</div>
</div>
</div>
</el-card>
</div>
</el-tab-pane>
<!-- Tab 2: 搜索排名 -->
<el-tab-pane label="搜索排名" name="rankings">
<div class="page-fade">
<el-card shadow="never" style="border-radius:var(--radius-md);margin-bottom:20px;">
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:10px;">
<span style="font-weight:600;font-size:15px;">
<el-icon style="vertical-align:-2px;"><IconSearch /></el-icon> 关键词排名追踪
</span>
<div style="display:flex;gap:10px;align-items:center;">
<el-select v-model="rankingEngineFilter" placeholder="搜索引擎" size="small" clearable style="width:140px;">
<el-option label="全部" value=""></el-option>
<el-option label="Bing" value="bing"></el-option>
<el-option label="Google" value="google"></el-option>
<el-option label="百度" value="baidu"></el-option>
<el-option label="Geo" value="geo"></el-option>
</el-select>
<el-button size="small" @click="fetchRankings" :loading="loadingRankings">刷新</el-button>
</div>
</div>
</template>
<!-- Overview mini stats -->
<div v-if="rankingStats.total > 0" style="display:flex;gap:16px;margin-bottom:16px;flex-wrap:wrap;">
<div style="padding:10px 16px;background:var(--color-bg);border-radius:var(--radius-sm);text-align:center;min-width:80px;">
<div style="font-size:20px;font-weight:700;color:var(--color-primary);">{{ rankingStats.total }}</div>
<div style="font-size:11px;color:var(--color-text-secondary);">总记录</div>
</div>
<div style="padding:10px 16px;background:var(--color-bg);border-radius:var(--radius-sm);text-align:center;min-width:80px;">
<div style="font-size:20px;font-weight:700;color:#67c23a;">{{ rankingStats.top10 }}</div>
<div style="font-size:11px;color:var(--color-text-secondary);">Top 10</div>
</div>
<div style="padding:10px 16px;background:var(--color-bg);border-radius:var(--radius-sm);text-align:center;min-width:80px;">
<div style="font-size:20px;font-weight:700;color:#e6a23c;">{{ rankingStats.aiCited }}</div>
<div style="font-size:11px;color:var(--color-text-secondary);">AI 引用</div>
</div>
</div>
<div v-if="loadingRankings" style="text-align:center;padding:30px;color:var(--color-text-secondary);">
<el-icon style="font-size:28px;margin-bottom:8px;display:block;" class="is-loading"><IconLoading /></el-icon>
加载中...
</div>
<div v-else-if="rankings.length === 0" style="text-align:center;padding:30px;color:var(--color-text-secondary);">
<el-icon style="font-size:40px;margin-bottom:8px;display:block;"><IconSearch /></el-icon>
暂无排名数据
</div>
<div v-else class="card-table">
<el-table :data="filteredRankings" stripe size="small">
<el-table-column label="关键词" min-width="160" show-overflow-tooltip>
<template #default="scope">{{ scope.row.keyword }}</template>
</el-table-column>
<el-table-column label="引擎" width="80" align="center">
<template #default="scope">
<el-tag size="small" type="info">{{ engineShort(scope.row.search_engine) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="排名" width="80" align="center">
<template #default="scope">
<span v-if="scope.row.position !== null && scope.row.position !== undefined"
class="pos-badge"
:class="scope.row.position <= 10 ? 'top10' : scope.row.position <= 30 ? 'top30' : 'low30'">
#{{ scope.row.position }}
</span>
<span v-else style="color:var(--color-text-secondary);font-size:12px;">-</span>
</template>
</el-table-column>
<el-table-column label="AI 引用" width="80" align="center">
<template #default="scope">
<el-tag v-if="scope.row.ai_cited" size="small" type="success"></el-tag>
<el-tag v-else size="small" type="info"></el-tag>
</template>
</el-table-column>
<el-table-column label="来源" width="100" show-overflow-tooltip>
<template #default="scope">{{ scope.row.ai_source || '-' }}</template>
</el-table-column>
<el-table-column label="检查日期" width="110">
<template #default="scope">{{ formatDate(scope.row.checked_at) }}</template>
</el-table-column>
</el-table>
</div>
<div v-else class="card-list-mobile">
<div v-for="r in filteredRankings" :key="r.id" class="card-item" style="background:var(--color-bg);border-radius:10px;padding:14px;margin-bottom:10px;border:1px solid var(--color-border);">
<div style="font-weight:600;font-size:14px;margin-bottom:6px;">{{ r.keyword }}</div>
<div style="display:flex;justify-content:space-between;font-size:12px;color:var(--color-text-regular);margin-bottom:4px;">
<span>引擎: {{ engineShort(r.search_engine) }}</span>
<span>日期: {{ formatDate(r.checked_at) }}</span>
</div>
<div style="display:flex;justify-content:space-between;font-size:12px;color:var(--color-text-regular);">
<span>排名: <span class="pos-badge" :class="r.position<=10?'top10':r.position<=30?'top30':'low30'" v-if="r.position!=null">#{{ r.position }}</span><span v-else>-</span></span>
<span>AI: <el-tag size="small" :type="r.ai_cited?'success':'info'">{{ r.ai_cited?'是':'否' }}</el-tag></span>
</div>
</div>
</div>
</el-card>
</div>
</el-tab-pane>
<!-- Tab 3: GEO 就绪度 -->
<el-tab-pane label="GEO 就绪度" name="geo">
<div class="page-fade">
<!-- GEO Overview -->
<el-row :gutter="16" style="margin-bottom:20px;">
<el-col :xs="12" :sm="8" :md="6" v-for="(item, key) in geoSummaryCards" :key="key">
<el-card shadow="hover" style="margin-bottom:12px;text-align:center;border-radius:var(--radius-md);">
<div style="font-size:28px;font-weight:800;color:{{ item.color }};">{{ item.value }}</div>
<div style="font-size:12px;color:var(--color-text-secondary);margin-top:4px;">{{ item.label }}</div>
</el-card>
</el-col>
</el-row>
<!-- GEO Factors checklist -->
<el-card shadow="never" style="border-radius:var(--radius-md);margin-bottom:20px;">
<template #header>
<span style="font-weight:600;font-size:15px;">
<el-icon style="vertical-align:-2px;"><IconCheck /></el-icon> GEO 优化检查清单
</span>
</template>
<div class="geo-factor-grid">
<div class="geo-factor-item" v-for="factor in geoFactors" :key="factor.key">
<span class="check" :class="factor.pass ? 'pass' : 'fail'">{{ factor.pass ? '&#10003;' : '&times;' }}</span>
<span>{{ factor.label }}</span>
</div>
</div>
</el-card>
<!-- GEO Scores table -->
<el-card shadow="never" style="border-radius:var(--radius-md);">
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center;">
<span style="font-weight:600;font-size:15px;">
<el-icon style="vertical-align:-2px;"><IconTrendCharts /></el-icon> 文章 GEO 评分
</span>
<el-radio-group v-model="geoSortBy" size="small" @change="fetchGeoScores">
<el-radio-button label="desc">最低优先</el-radio-button>
<el-radio-button label="asc">最高优先</el-radio-button>
</el-radio-group>
</div>
</template>
<div v-if="loadingGeo" style="text-align:center;padding:30px;color:var(--color-text-secondary);">
<el-icon style="font-size:28px;margin-bottom:8px;display:block;" class="is-loading"><IconLoading /></el-icon>
加载中...
</div>
<div v-else-if="geoScores.length === 0" style="text-align:center;padding:30px;color:var(--color-text-secondary);">
<el-icon style="font-size:40px;margin-bottom:8px;display:block;"><IconTrendCharts /></el-icon>
暂无 GEO 评分数据
</div>
<div v-else class="card-table">
<el-table :data="sortedGeoScores" stripe size="small">
<el-table-column label="文章" min-width="180" show-overflow-tooltip>
<template #default="scope">{{ scope.row.article_id || scope.row.topic_id || '-' }}</template>
</el-table-column>
<el-table-column label="平台" width="80" align="center">
<template #default="scope">{{ platformName(scope.row.platform) }}</template>
</el-table-column>
<el-table-column label="总分" width="100" align="center">
<template #default="scope">
<span :style="{fontWeight:700,color: scoreColor(scope.row.total_score)}">{{ scope.row.total_score }}/100</span>
</template>
</el-table-column>
<el-table-column label="评分条" width="140">
<template #default="scope">
<div class="score-bar-wrap">
<div class="score-bar" :class="scoreBarClass(scope.row.total_score)" :style="{width: (scope.row.total_score||0)+'%'}"></div>
</div>
</template>
</el-table-column>
<el-table-column label="Schema" width="70" align="center">
<template #default="scope">
<el-tag v-if="scope.row.has_schema" size="small" type="success"></el-tag>
<el-tag v-else size="small" type="info"></el-tag>
</template>
</el-table-column>
<el-table-column label="可读性" width="90" align="center">
<template #default="scope">
<span>{{ scope.row.readability_score || 0 }}</span>
</template>
</el-table-column>
<el-table-column label="标题结构" width="100" align="center">
<template #default="scope">
<span>{{ scope.row.heading_structure_score || 0 }}</span>
</template>
</el-table-column>
<el-table-column label="检查时间" width="140">
<template #default="scope">{{ formatDate(scope.row.checked_at) }}</template>
</el-table-column>
</el-table>
</div>
<div v-else class="card-list-mobile">
<div v-for="g in sortedGeoScores" :key="g.id" class="card-item" style="background:var(--color-bg);border-radius:10px;padding:14px;margin-bottom:10px;border:1px solid var(--color-border);">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
<span style="font-weight:700;font-size:14px;">{{ g.article_id || g.topic_id || '-' }}</span>
<span style="font-weight:800;font-size:16px;" :style="{color: scoreColor(g.total_score)}">{{ g.total_score }}/100</span>
</div>
<div class="score-bar-wrap" style="margin-bottom:8px;">
<div class="score-bar" :class="scoreBarClass(g.total_score)" :style="{width: (g.total_score||0)+'%'}"></div>
</div>
<div style="display:flex;justify-content:space-between;font-size:12px;color:var(--color-text-regular);flex-wrap:wrap;gap:4px;">
<span>平台: {{ platformName(g.platform) }}</span>
<span>Schema: {{ g.has_schema?'有':'无' }}</span>
<span>可读性: {{ g.readability_score||0 }}</span>
<span>标题: {{ g.heading_structure_score||0 }}</span>
</div>
<div style="font-size:11px;color:var(--color-text-secondary);margin-top:6px;">{{ formatDate(g.checked_at) }}</div>
</div>
</div>
</el-card>
</div>
</el-tab-pane>
<!-- Tab 4: 平台对比 -->
<el-tab-pane label="平台对比" name="comparison">
<div class="page-fade">
<el-card shadow="never" style="border-radius:var(--radius-md);margin-bottom:20px;">
<template #header>
<span style="font-weight:600;font-size:15px;">
<el-icon style="vertical-align:-2px;"><IconMonitor /></el-icon> 平台数据对比
</span>
</template>
<div v-if="loadingComparison" style="text-align:center;padding:30px;color:var(--color-text-secondary);">
<el-icon style="font-size:28px;margin-bottom:8px;display:block;" class="is-loading"><IconLoading /></el-icon>
加载中...
</div>
<div v-else-if="platformData.length === 0" style="text-align:center;padding:30px;color:var(--color-text-secondary);">
<el-icon style="font-size:40px;margin-bottom:8px;display:block;"><IconMonitor /></el-icon>
暂无平台数据
</div>
<div v-else>
<!-- Bar chart comparison -->
<div style="margin-bottom:24px;">
<h4 style="font-size:14px;font-weight:600;color:var(--color-text-secondary);margin-bottom:14px;">阅读对比</h4>
<div v-for="p in platformData" :key="p.platform" class="platform-bar-row">
<div class="platform-bar-label">{{ platformName(p.platform) }}</div>
<div class="platform-bar-track">
<div class="platform-bar-fill" :class="p.platform" :style="{width: barWidth(p.total_views, maxViews)}">
{{ formatNum(p.total_views) }}
</div>
</div>
</div>
</div>
<div style="margin-bottom:24px;">
<h4 style="font-size:14px;font-weight:600;color:var(--color-text-secondary);margin-bottom:14px;">点赞对比</h4>
<div v-for="p in platformData" :key="p.platform" class="platform-bar-row">
<div class="platform-bar-label">{{ platformName(p.platform) }}</div>
<div class="platform-bar-track">
<div class="platform-bar-fill" :class="p.platform" :style="{width: barWidth(p.total_likes, maxLikes)}">
{{ formatNum(p.total_likes) }}
</div>
</div>
</div>
</div>
<div>
<h4 style="font-size:14px;font-weight:600;color:var(--color-text-secondary);margin-bottom:14px;">文章数对比</h4>
<div v-for="p in platformData" :key="p.platform" class="platform-bar-row">
<div class="platform-bar-label">{{ platformName(p.platform) }}</div>
<div class="platform-bar-track">
<div class="platform-bar-fill" :class="p.platform" :style="{width: barWidth(p.count, maxCount)}">
{{ p.count }} 篇
</div>
</div>
</div>
</div>
</div>
</el-card>
<!-- Detailed metrics table -->
<el-card shadow="never" style="border-radius:var(--radius-md);">
<template #header>
<span style="font-weight:600;font-size:15px;">详细指标</span>
</template>
<div class="card-table">
<el-table :data="platformData" stripe size="small">
<el-table-column label="平台" width="120">
<template #default="scope">{{ platformName(scope.row.platform) }}</template>
</el-table-column>
<el-table-column label="文章数" width="80" align="center">
<template #default="scope">{{ scope.row.count }}</template>
</el-table-column>
<el-table-column label="总阅读" width="100" align="right">
<template #default="scope">{{ formatNum(scope.row.total_views) }}</template>
</el-table-column>
<el-table-column label="平均阅读" width="100" align="right">
<template #default="scope">{{ Math.round(scope.row.avg_views || 0) }}</template>
</el-table-column>
<el-table-column label="总点赞" width="100" align="right">
<template #default="scope">{{ formatNum(scope.row.total_likes) }}</template>
</el-table-column>
<el-table-column label="平均点赞" width="100" align="right">
<template #default="scope">{{ Math.round(scope.row.avg_likes || 0) }}</template>
</el-table-column>
<el-table-column label="总评论" width="100" align="right">
<template #default="scope">{{ formatNum(scope.row.total_comments || 0) }}</template>
</el-table-column>
</el-table>
</div>
<div class="card-list-mobile">
<div v-for="p in platformData" :key="p.platform" class="card-item" style="background:var(--color-bg);border-radius:10px;padding:14px;margin-bottom:10px;border:1px solid var(--color-border);">
<div style="font-weight:700;font-size:15px;margin-bottom:10px;">{{ platformName(p.platform) }}</div>
<div style="display:flex;justify-content:space-between;font-size:12px;color:var(--color-text-regular);margin-bottom:4px;">
<span>文章数</span><span style="font-weight:600;">{{ p.count }}</span>
</div>
<div style="display:flex;justify-content:space-between;font-size:12px;color:var(--color-text-regular);margin-bottom:4px;">
<span>总阅读</span><span style="font-weight:600;">{{ formatNum(p.total_views) }}</span>
</div>
<div style="display:flex;justify-content:space-between;font-size:12px;color:var(--color-text-regular);margin-bottom:4px;">
<span>平均阅读</span><span style="font-weight:600;">{{ Math.round(p.avg_views||0) }}</span>
</div>
<div style="display:flex;justify-content:space-between;font-size:12px;color:var(--color-text-regular);margin-bottom:4px;">
<span>总点赞</span><span style="font-weight:600;">{{ formatNum(p.total_likes) }}</span>
</div>
<div style="display:flex;justify-content:space-between;font-size:12px;color:var(--color-text-regular);">
<span>平均点赞</span><span style="font-weight:600;">{{ Math.round(p.avg_likes||0) }}</span>
</div>
</div>
</div>
</el-card>
</div>
</el-tab-pane>
</el-tabs>
</main>
</div>
</div>
<script src="vue.global.prod.js"></script>
<script src="icon-components.js"></script>
<script src="element-plus.full.js"></script>
<script>
const InsightsApp = {
data() {
return {
currentUser: { username: '' },
isAdmin: false,
activeTab: 'overview',
// Data
dashboard: { total_topics: 0, topics_by_status: {}, total_published: 0, total_views: 0, total_likes: 0, avg_engagement_rate: 0, top_topics: [], recent_metrics: [] },
topTopics: [],
recentMetrics: [],
rankings: [],
rankingEngineFilter: '',
loadingRankings: false,
geoScores: [],
loadingGeo: false,
geoSortBy: 'desc',
platformData: [],
loadingComparison: false,
maxViews: 1,
maxLikes: 1,
maxCount: 1,
}
},
computed: {
summary() {
return {
total_published: this.dashboard.total_published || 0,
total_views: this.dashboard.total_views || 0,
total_likes: this.dashboard.total_likes || 0,
avg_engagement: (this.dashboard.avg_engagement_rate || 0).toFixed(1),
};
},
overviewCards() {
const s = this.summary;
return [
{ label: '已发布', value: s.total_published, color: 'var(--color-primary)' },
{ label: '总阅读', value: this.formatNum(s.total_views), color: '#67c23a' },
{ label: '总点赞', value: this.formatNum(s.total_likes), color: '#e6a23c' },
{ label: '平均互动率', value: s.avg_engagement + '%', color: '#f56c6c' },
];
},
filteredRankings() {
if (!this.rankingEngineFilter) return this.rankings;
return this.rankings.filter(r => r.search_engine === this.rankingEngineFilter);
},
rankingStats() {
const data = this.filteredRankings;
return {
total: data.length,
top10: data.filter(r => r.position != null && r.position <= 10).length,
aiCited: data.filter(r => r.ai_cited).length,
};
},
geoSummaryCards() {
const total = this.geoScores.length;
if (total === 0) return [
{ label: '总评分', value: '-', color: 'var(--color-primary)' },
{ label: '平均分', value: '-', color: '#67c23a' },
{ label: '高分(>70)', value: '0', color: '#67c23a' },
{ label: '低分(<40)', value: '0', color: '#f56c6c' },
];
const scores = this.geoScores.map(g => g.total_score || 0);
const avg = scores.reduce((a, b) => a + b, 0) / scores.length;
const high = scores.filter(s => s > 70).length;
const low = scores.filter(s => s < 40).length;
return [
{ label: '总评分', value: total, color: 'var(--color-primary)' },
{ label: '平均分', value: avg.toFixed(1), color: '#67c23a' },
{ label: '高分(>70)', value: high, color: '#67c23a' },
{ label: '低分(<40)', value: low, color: '#f56c6c' },
];
},
geoFactors() {
if (this.geoScores.length === 0) return [];
const all = this.geoScores;
return [
{ key: 'schema', label: '结构化数据 (Schema)', pass: all.some(g => g.has_schema) },
{ key: 'faq', label: 'FAQ 格式', pass: all.some(g => g.has_faq_format) },
{ key: 'howto', label: 'HowTo 格式', pass: all.some(g => g.has_howto_format) },
{ key: 'citations', label: '数据引用', pass: all.some(g => g.has_citations) },
{ key: 'readability', label: '可读性 > 60', pass: all.some(g => (g.readability_score || 0) > 60) },
{ key: 'headings', label: '标题结构 > 60', pass: all.some(g => (g.heading_structure_score || 0) > 60) },
{ key: 'wordcount', label: '字数 > 1000', pass: all.some(g => (g.word_count || 0) > 1000) },
{ key: 'geoscore', label: 'GEO 总分 > 60', pass: all.some(g => (g.total_score || 0) > 60) },
];
},
sortedGeoScores() {
const arr = [...this.geoScores];
arr.sort((a, b) => this.geoSortBy === 'asc'
? (b.total_score || 0) - (a.total_score || 0)
: (a.total_score || 0) - (b.total_score || 0)
);
return arr;
},
},
methods: {
handleLogout() { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; },
redirectToPage(page) { window.location.href = page.startsWith('/') ? page : '/' + page; },
checkAuth() {
const token = localStorage.getItem('authToken');
if (!token) { window.location.href = '/login.html'; return; }
fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token } })
.then(r => r.ok ? r.json() : Promise.reject())
.then(data => {
this.currentUser = data.user;
this.isAdmin = data.user.role === 'admin';
this.loadAllTabs();
})
.catch(() => { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; });
},
async loadAllTabs() {
await Promise.all([
this.fetchDashboard(),
this.fetchRankings(),
this.fetchGeoScores(),
this.fetchPlatformData(),
]);
},
// --- Dashboard / Overview ---
async fetchDashboard() {
try {
const token = localStorage.getItem('authToken');
const res = await fetch('/api/metrics/dashboard?days=365', { headers: { 'Authorization': 'Bearer ' + token } });
if (res.ok) {
this.dashboard = await res.json();
this.topTopics = (this.dashboard.top_topics || []).map(t => {
const views = t.total_views || 0;
const likes = t.total_likes || 0;
return { ...t, engagement_pct: views > 0 ? ((likes / views) * 100) : 0 };
});
this.recentMetrics = this.dashboard.recent_metrics || [];
}
} catch (e) { console.error('Dashboard error:', e); }
},
// --- Rankings ---
async fetchRankings() {
this.loadingRankings = true;
try {
const token = localStorage.getItem('authToken');
const res = await fetch('/api/seo/rankings?limit=200&days=365', { headers: { 'Authorization': 'Bearer ' + token } });
if (res.ok) {
const json = await res.json();
this.rankings = json.data || json.rankings || [];
}
} catch (e) { console.error('Rankings error:', e); }
finally { this.loadingRankings = false; }
},
// --- GEO ---
async fetchGeoScores() {
this.loadingGeo = true;
try {
const token = localStorage.getItem('authToken');
const res = await fetch('/api/seo/geo/readiness?limit=200&days=365', { headers: { 'Authorization': 'Bearer ' + token } });
if (res.ok) {
const json = await res.json();
this.geoScores = json.data || [];
}
} catch (e) { console.error('GEO error:', e); }
finally { this.loadingGeo = false; }
},
// --- Platform Comparison ---
async fetchPlatformData() {
this.loadingComparison = true;
try {
const token = localStorage.getItem('authToken');
const res = await fetch('/api/metrics/by-platform', { headers: { 'Authorization': 'Bearer ' + token } });
if (res.ok) {
this.platformData = await res.json();
this.maxViews = Math.max(...this.platformData.map(p => p.total_views || 1), 1);
this.maxLikes = Math.max(...this.platformData.map(p => p.total_likes || 1), 1);
this.maxCount = Math.max(...this.platformData.map(p => p.count || 1), 1);
}
} catch (e) { console.error('Platform data error:', e); }
finally { this.loadingComparison = false; }
},
// --- Helpers ---
formatNum(n) {
if (n == null) return '0';
if (typeof n === 'string') n = parseInt(n) || 0;
if (n >= 10000) return (n / 10000).toFixed(1) + 'w';
if (n >= 1000) return (n / 1000).toFixed(1) + 'k';
return String(n);
},
formatDate(dt) {
if (!dt) return '-';
try {
const d = new Date(dt);
return d.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' });
} catch { return dt; }
},
platformName(p) {
return { zhihu: '知乎', wechat: '微信', xiaohongshu: '小红书', default: p }[p] || p;
},
engineShort(e) {
return { bing: 'Bing', google: 'Google', baidu: '百度', geo: 'Geo' }[e] || (e || '-');
},
engagementRate(rate) {
return rate || 0;
},
engagementColor(rate) {
if (rate >= 5) return '#67c23a';
if (rate >= 2) return '#e6a23c';
return '#f56c6c';
},
scoreColor(score) {
if (score >= 70) return '#67c23a';
if (score >= 40) return '#e6a23c';
return '#f56c6c';
},
scoreBarClass(score) {
if (score >= 70) return 'green';
if (score >= 40) return 'yellow';
return 'red';
},
barWidth(value, max) {
if (!max || max === 0) return '0%';
const pct = Math.round((value / max) * 100);
return Math.max(pct, 2) + '%';
},
},
mounted() {
this.checkAuth();
},
};
const app = Vue.createApp(InsightsApp);
app.use(ElementPlus);
if (window.installIcons) { window.installIcons(app); }
if (window.installUniNav) { window.installUniNav(app); }
app.mount('#app');
</script>
<script src="ai-assistant.js"></script>
</body>
</html>