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:
@@ -1,13 +0,0 @@
|
||||
---
|
||||
active: true
|
||||
iteration: 1
|
||||
max_iterations: 500
|
||||
completion_promise: "DONE"
|
||||
initial_completion_promise: "DONE"
|
||||
started_at: "2026-06-17T06:11:18.800Z"
|
||||
session_id: "ses_1dede8a7affeG6DxtfPUOviXrp"
|
||||
ultrawork: true
|
||||
strategy: "continue"
|
||||
message_count_at_start: 8133
|
||||
---
|
||||
全部按照你的建议执行启动
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"sessionID": "ses_1dede8a7affeG6DxtfPUOviXrp",
|
||||
"updatedAt": "2026-06-15T14:52:36.537Z",
|
||||
"updatedAt": "2026-06-17T07:11:26.600Z",
|
||||
"sources": {
|
||||
"background-task": {
|
||||
"state": "idle",
|
||||
"updatedAt": "2026-06-15T14:52:36.537Z"
|
||||
"updatedAt": "2026-06-17T07:11:26.600Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -160,7 +160,8 @@ def update_article_content(
|
||||
if not article:
|
||||
article = Article(
|
||||
id=article_id, topic_id=topic_id, platform=platform,
|
||||
file_path=f"db:{article_id}", status="draft"
|
||||
file_path=f"db:{article_id}", status="draft",
|
||||
org_id=topic.org_id
|
||||
)
|
||||
db.add(article)
|
||||
article.html_content = html_content
|
||||
|
||||
@@ -97,7 +97,7 @@ def create_entry(
|
||||
if current_user.role != "admin" and topic.org_id != current_user.org_id:
|
||||
raise HTTPException(status_code=404, detail="选题不存在")
|
||||
|
||||
entry = ContentCalendar(**data.model_dump())
|
||||
entry = ContentCalendar(**data.model_dump(), org_id=current_user.org_id or "default")
|
||||
db.add(entry)
|
||||
db.commit()
|
||||
db.refresh(entry)
|
||||
@@ -119,6 +119,13 @@ def update_entry(
|
||||
entry = db.query(ContentCalendar).filter(ContentCalendar.id == entry_id).first()
|
||||
if not entry:
|
||||
raise HTTPException(status_code=404, detail="日历条目不存在")
|
||||
# Verify ownership via topic org_id
|
||||
if entry.topic_id:
|
||||
topic_check = db.query(Topic).filter(Topic.id == entry.topic_id).first()
|
||||
if topic_check and current_user.role != "admin" and topic_check.org_id != current_user.org_id:
|
||||
raise HTTPException(status_code=404, detail="日历条目不存在")
|
||||
if not entry.topic_id and current_user.role != "admin" and entry.org_id != current_user.org_id:
|
||||
raise HTTPException(status_code=404, detail="日历条目不存在")
|
||||
|
||||
if entry.topic_id:
|
||||
topic = db.query(Topic).filter(Topic.id == entry.topic_id).first()
|
||||
@@ -156,6 +163,8 @@ def delete_entry(
|
||||
topic = db.query(Topic).filter(Topic.id == entry.topic_id).first()
|
||||
if topic and current_user.role != "admin" and topic.org_id != current_user.org_id:
|
||||
raise HTTPException(status_code=404, detail="日历条目不存在")
|
||||
if not entry.topic_id and current_user.role != "admin" and entry.org_id != current_user.org_id:
|
||||
raise HTTPException(status_code=404, detail="日历条目不存在")
|
||||
db.delete(entry)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
@@ -232,7 +241,8 @@ def create_from_topic(
|
||||
title=topic.title,
|
||||
planned_date=planned_date,
|
||||
platform=platform,
|
||||
created_by=current_user.username
|
||||
created_by=current_user.username,
|
||||
org_id=current_user.org_id or "default"
|
||||
)
|
||||
db.add(entry)
|
||||
db.commit()
|
||||
|
||||
@@ -179,7 +179,7 @@ def create_metric(
|
||||
db.refresh(existing)
|
||||
return existing
|
||||
|
||||
metric = ContentMetrics(**data.model_dump(), last_fetched=datetime.now())
|
||||
metric = ContentMetrics(**data.model_dump(), last_fetched=datetime.now(), org_id=topic.org_id)
|
||||
db.add(metric)
|
||||
db.commit()
|
||||
db.refresh(metric)
|
||||
@@ -401,6 +401,7 @@ def fetch_zhihu_metrics(
|
||||
metric = ContentMetrics(
|
||||
topic_id=data.topic_id,
|
||||
platform=platform,
|
||||
org_id=topic.org_id,
|
||||
**metric_data,
|
||||
last_fetched=datetime.now()
|
||||
)
|
||||
|
||||
@@ -69,7 +69,8 @@ async def create_publish_record(
|
||||
action='publish',
|
||||
status='success',
|
||||
operator=operator,
|
||||
description=f"选题 {req.topic_id} 发布到 {PLATFORM_LABELS.get(platform, platform)}"
|
||||
description=f"选题 {req.topic_id} 发布到 {PLATFORM_LABELS.get(platform, platform)}",
|
||||
org_id=topic.org_id
|
||||
)
|
||||
db.add(record)
|
||||
results.append(PublishResult(
|
||||
|
||||
@@ -86,7 +86,10 @@ def get_geo_overview(
|
||||
"""GEO 概览 — AI 搜索引用 + 就绪度评分"""
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
|
||||
|
||||
base = db.query(SearchRanking).filter(SearchRanking.checked_at >= cutoff)
|
||||
# Filter geo checks by org via articles
|
||||
base = db.query(SearchRanking)
|
||||
base = _apply_org_filter(base, current_user, db)
|
||||
base = base.filter(SearchRanking.checked_at >= cutoff)
|
||||
total_geo = base.filter(SearchRanking.search_engine == "geo").count()
|
||||
total_cited = base.filter(
|
||||
SearchRanking.search_engine == "geo",
|
||||
@@ -138,7 +141,14 @@ def get_geo_readiness_scores(
|
||||
):
|
||||
"""获取 GEO 就绪度评分列表"""
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
|
||||
scores = db.query(GeoReadinessScore).filter(
|
||||
# Join via articles to filter by org
|
||||
scores_query = db.query(GeoReadinessScore).join(
|
||||
ArticleModel, GeoReadinessScore.article_id == ArticleModel.id, isouter=True
|
||||
)
|
||||
of = org_filter(current_user, ArticleModel)
|
||||
if of is not True:
|
||||
scores_query = scores_query.filter(of)
|
||||
scores = scores_query.filter(
|
||||
GeoReadinessScore.checked_at >= cutoff
|
||||
).order_by(desc(GeoReadinessScore.checked_at)).limit(limit).all()
|
||||
return {"ok": True, "data": [s.to_dict() for s in scores]}
|
||||
|
||||
@@ -16,11 +16,14 @@ router = APIRouter(prefix="/api/tasks", tags=["tasks"])
|
||||
|
||||
def _check_task_org(task, current_user, db):
|
||||
"""Check if task's topic belongs to user's org"""
|
||||
if current_user.role == "admin" or not task.topic_id:
|
||||
if current_user.role == "admin":
|
||||
return True
|
||||
if task.topic_id:
|
||||
topic = db.query(Topic).filter(Topic.id == task.topic_id).first()
|
||||
if topic and topic.org_id != current_user.org_id:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
elif current_user.role != "admin" and task.org_id != current_user.org_id:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
return True
|
||||
|
||||
|
||||
@@ -87,7 +90,8 @@ def create_task(
|
||||
topic_id=data.topic_id,
|
||||
stage=data.stage,
|
||||
status="pending",
|
||||
created_by=data.created_by or current_user.username
|
||||
created_by=data.created_by or current_user.username,
|
||||
org_id=current_user.org_id or "default"
|
||||
)
|
||||
db.add(task)
|
||||
db.commit()
|
||||
@@ -438,7 +442,8 @@ def run_creator_task(
|
||||
stage="creator",
|
||||
status="running",
|
||||
started_at=now,
|
||||
created_by=current_user.username
|
||||
created_by=current_user.username,
|
||||
org_id=current_user.org_id or "default"
|
||||
)
|
||||
db.add(task)
|
||||
db.commit()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 ? '✓' : '×' }}</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>
|
||||
@@ -121,23 +121,20 @@
|
||||
|
||||
function getCurrentPage() {
|
||||
var path = window.location.pathname;
|
||||
if (path === '/index.html') return 'dashboard';
|
||||
if (path === '/index.html') return 'workspace';
|
||||
var m = path.match(/\/(\w+)\.html/);
|
||||
return m ? m[1] : 'dashboard';
|
||||
return m ? m[1] : 'workspace';
|
||||
}
|
||||
|
||||
function navItems(isAdmin) {
|
||||
var items = [
|
||||
{ key: 'dashboard', label: '仪表盘', page: 'index.html' },
|
||||
{ key: 'topics', label: '选题', page: 'topics.html' },
|
||||
{ key: 'metrics', label: '数据', page: 'metrics.html' },
|
||||
{ key: 'calendar', label: '日历', page: 'calendar.html' },
|
||||
{ key: 'assets', label: '素材', page: 'assets.html' },
|
||||
{ key: 'tasks', label: '任务', page: 'tasks.html' },
|
||||
|
||||
{ key: 'workspace', label: '工作台', page: 'index.html' },
|
||||
{ key: 'factory', label: '内容工厂', page: 'factory.html' },
|
||||
{ key: 'insights', label: '数据洞察', page: 'insights.html' },
|
||||
{ key: 'assets', label: '资产库', page: 'assets.html' },
|
||||
];
|
||||
if (isAdmin) {
|
||||
items.push({ key: 'admin', label: '系统', page: 'admin.html', admin: true });
|
||||
items.push({ key: 'admin', label: '系统管理', page: 'admin.html', admin: true });
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user