302efd5f2d
Adds: v-cloak fix, mobile card-list views for all tables, responsive CSS breakpoints, early auth redirect, CDN script reorder to head, ElementPlusIconsVue safety check, global ElMessage reference. Moves async mounted to .then() pattern to avoid Vue 3 SFC issues. Ultraworked with Sisyphus Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
653 lines
48 KiB
HTML
653 lines
48 KiB
HTML
<!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>
|
|
[v-cloak] { display: none; }
|
|
.data-table { display: block; }
|
|
@media (max-width: 768px) { .data-table { display: none; } }
|
|
.stat-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; margin-bottom: 16px; }
|
|
.stat-card { background: #f8f9ff; border-radius: 10px; padding: 16px; text-align: center; }
|
|
.stat-card .num { font-size: 28px; font-weight: 700; color: #2563eb; }
|
|
.stat-card .label { font-size: 12px; color: #909399; margin-top: 4px; }
|
|
.search-engine-tag { margin: 2px; }
|
|
@media (max-width: 768px) {
|
|
.page-header { flex-direction: column; align-items: flex-start !important; gap: 8px; }
|
|
.filter-bar { display: flex; flex-wrap: wrap; gap: 4px; }
|
|
.filter-bar .el-button { margin-left: 0 !important; }
|
|
.stat-cards { grid-template-columns: repeat(2, 1fr); }
|
|
.toolbar { flex-wrap: wrap; gap: 8px; }
|
|
}
|
|
</style>
|
|
<script src="uni-nav.js"></script>
|
|
<script src="vue.global.prod.js"></script>
|
|
<script src="element-plus.full.js"></script>
|
|
<script src="icon-components.js"></script>
|
|
</head>
|
|
<body>
|
|
<div id="app" v-cloak>
|
|
<uni-nav title="外推营销" :username="currentUser.username" :is-admin="isAdmin" current-page="campaigns" @navigate="redirectToPage" @logout="logout">
|
|
</uni-nav>
|
|
<div class="main-content">
|
|
<main class="content-area">
|
|
<div class="card page-fade">
|
|
<div class="page-header">
|
|
<h2 class="page-title"><el-icon style="vertical-align:-2px;"><IconMenu /></el-icon> 外推营销</h2>
|
|
<div class="filter-bar">
|
|
<el-button size="default" :type="activeTab === 'overview' ? 'primary' : ''" @click="switchTab('overview')">概览</el-button>
|
|
<el-button size="default" :type="activeTab === 'products' ? 'primary' : ''" @click="switchTab('products')">产品管理</el-button>
|
|
<el-button size="default" :type="activeTab === 'campaigns' ? 'primary' : ''" @click="switchTab('campaigns')">活动管理</el-button>
|
|
<el-button size="default" :type="activeTab === 'rankings' ? 'primary' : ''" @click="switchTab('rankings')">关键词排名</el-button>
|
|
<el-button size="default" :type="activeTab === 'audits' ? 'primary' : ''" @click="switchTab('audits')">SEO审计</el-button>
|
|
<el-button size="default" :type="activeTab === 'tasks' ? 'primary' : ''" @click="switchTab('tasks')">优化建议</el-button>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="activeTab === 'overview'">
|
|
<div v-if="loading" class="card-loading">加载中...</div>
|
|
<template v-else>
|
|
<div class="stat-cards">
|
|
<div class="stat-card"><div class="num">{{ overview.products || 0 }}</div><div class="label">推广产品</div></div>
|
|
<div class="stat-card"><div class="num">{{ overview.campaigns || 0 }}</div><div class="label">推广活动</div></div>
|
|
<div class="stat-card"><div class="num">{{ overview.active_campaigns || 0 }}</div><div class="label">进行中</div></div>
|
|
<div class="stat-card"><div class="num">{{ overview.audits || 0 }}</div><div class="label">SEO审计</div></div>
|
|
<div class="stat-card"><div class="num">{{ overview.keywords || 0 }}</div><div class="label">追踪关键词</div></div>
|
|
<div class="stat-card"><div class="num">{{ overview.open_tasks || 0 }}</div><div class="label">待处理建议</div></div>
|
|
</div>
|
|
<div v-if="overview.type_breakdown" style="margin-top:16px;">
|
|
<h4 style="font-size:14px;color:#303133;margin-bottom:8px;">产品类型分布</h4>
|
|
<div style="display:flex;gap:12px;flex-wrap:wrap;">
|
|
<el-tag v-for="(cnt, type) in overview.type_breakdown" :key="type" size="large">{{ typeLabel(type) }}: {{ cnt }}</el-tag>
|
|
</div>
|
|
</div>
|
|
<div style="margin-top:20px;">
|
|
<el-button type="primary" @click="switchTab('products')">管理推广产品</el-button>
|
|
<el-button @click="switchTab('campaigns')">查看活动</el-button>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<div v-if="activeTab === 'products'">
|
|
<div class="toolbar">
|
|
<el-button type="primary" size="small" @click="showProductDialog = true">新增产品</el-button>
|
|
<span style="font-size:13px;color:#909399;margin-left:8px;">共 {{ products.length }} 个</span>
|
|
</div>
|
|
<div v-if="loading" class="card-loading">加载中...</div>
|
|
<template v-else-if="products.length === 0">
|
|
<div class="empty-state">
|
|
<div class="empty-text">暂无推广产品,点击上方按钮添加</div>
|
|
</div>
|
|
</template>
|
|
<template v-else>
|
|
<el-table :data="products" border stripe class="data-table" style="width:100%">
|
|
<el-table-column prop="id" label="ID" width="60"></el-table-column>
|
|
<el-table-column prop="name" label="产品名称" min-width="120"></el-table-column>
|
|
<el-table-column label="类型" width="110">
|
|
<template #default="s">{{ typeLabel(s.row.type) }}</template>
|
|
</el-table-column>
|
|
<el-table-column prop="url" label="URL" min-width="180">
|
|
<template #default="s"><a :href="s.row.url" target="_blank" style="color:#409eff;">{{ s.row.url || '-' }}</a></template>
|
|
</el-table-column>
|
|
<el-table-column prop="domain" label="域名" width="140"></el-table-column>
|
|
<el-table-column label="操作" width="150" fixed="right">
|
|
<template #default="s">
|
|
<el-button size="small" @click="editProduct(s.row)">编辑</el-button>
|
|
<el-button size="small" type="danger" @click="deleteProduct(s.row)">删除</el-button>
|
|
</template>
|
|
</el-table-column>
|
|
</el-table>
|
|
<div class="card-list-mobile">
|
|
<div v-for="p in products" :key="p.id" class="card-item">
|
|
<div class="card-row"><span class="card-label">名称</span><span class="card-value">{{ p.name }}</span></div>
|
|
<div class="card-row"><span class="card-label">类型</span><span class="card-value">{{ typeLabel(p.type) }}</span></div>
|
|
<div class="card-row"><span class="card-label">URL</span><span class="card-value"><a :href="p.url" target="_blank" style="color:#409eff;word-break:break-all;">{{ p.url || '-' }}</a></span></div>
|
|
<div class="card-actions">
|
|
<el-button size="small" @click="editProduct(p)">编辑</el-button>
|
|
<el-button size="small" type="danger" @click="deleteProduct(p)">删除</el-button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div v-if="products.length > pageSize" style="display:flex;justify-content:center;margin:12px 0;">
|
|
<el-pagination background layout="prev, pager, next" :total="products.length" :page-size="pageSize" :current-page="productPage" @current-change="productPage = $event"></el-pagination>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<div v-if="activeTab === 'campaigns'">
|
|
<div class="toolbar">
|
|
<el-button type="primary" size="small" @click="showCampaignDialog = true">新增活动</el-button>
|
|
<span style="font-size:13px;color:#909399;margin-left:8px;">共 {{ campaigns.length }} 个</span>
|
|
</div>
|
|
<div v-if="loading" class="card-loading">加载中...</div>
|
|
<template v-else-if="campaigns.length === 0">
|
|
<div class="empty-state"><div class="empty-text">暂无推广活动</div></div>
|
|
</template>
|
|
<template v-else>
|
|
<el-table :data="campaigns" border stripe class="data-table" style="width:100%">
|
|
<el-table-column prop="name" label="活动名称" min-width="120"></el-table-column>
|
|
<el-table-column prop="product_name" label="关联产品" width="120"></el-table-column>
|
|
<el-table-column label="状态" width="90">
|
|
<template #default="s">
|
|
<el-tag :type="s.row.status === 'active' ? 'success' : s.row.status === 'paused' ? 'warning' : 'info'" size="small">{{ { active: '进行中', paused: '已暂停', completed: '已完成' }[s.row.status] || s.row.status }}</el-tag>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column label="目标引擎" min-width="140">
|
|
<template #default="s">
|
|
<el-tag v-for="e in (s.row.target_engines || [])" :key="e" size="small" class="search-engine-tag">{{ engineLabel(e) }}</el-tag>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column label="关键词数" width="80">
|
|
<template #default="s">{{ (s.row.keywords || []).length }}</template>
|
|
</el-table-column>
|
|
<el-table-column label="操作" width="200" fixed="right">
|
|
<template #default="s">
|
|
<el-button size="small" @click="viewCampaignKeywords(s.row)">关键词</el-button>
|
|
<el-button size="small" @click="editCampaign(s.row)">编辑</el-button>
|
|
<el-button size="small" type="danger" @click="deleteCampaign(s.row)">删除</el-button>
|
|
</template>
|
|
</el-table-column>
|
|
</el-table>
|
|
<div class="card-list-mobile">
|
|
<div v-for="c in campaigns" :key="c.id" class="card-item">
|
|
<div class="card-row"><span class="card-label">活动</span><span class="card-value">{{ c.name }}</span></div>
|
|
<div class="card-row"><span class="card-label">产品</span><span class="card-value">{{ c.product_name || '-' }}</span></div>
|
|
<div class="card-row"><span class="card-label">状态</span><span class="card-value"><el-tag :type="c.status === 'active' ? 'success' : c.status === 'paused' ? 'warning' : 'info'" size="small">{{ { active: '进行中', paused: '已暂停', completed: '已完成' }[c.status] || c.status }}</el-tag></span></div>
|
|
<div class="card-row"><span class="card-label">引擎</span><span class="card-value"><el-tag v-for="e in (c.target_engines || [])" :key="e" size="small" class="search-engine-tag">{{ engineLabel(e) }}</el-tag></span></div>
|
|
<div class="card-actions">
|
|
<el-button size="small" @click="viewCampaignKeywords(c)">关键词</el-button>
|
|
<el-button size="small" @click="editCampaign(c)">编辑</el-button>
|
|
<el-button size="small" type="danger" @click="deleteCampaign(c)">删除</el-button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div v-if="campaigns.length > pageSize" style="display:flex;justify-content:center;margin:12px 0;">
|
|
<el-pagination background layout="prev, pager, next" :total="campaigns.length" :page-size="pageSize" :current-page="campaignPage" @current-change="campaignPage = $event"></el-pagination>
|
|
</div>
|
|
</template>
|
|
|
|
<div v-if="selectedCampaign" style="margin-top:20px;border-top:1px solid #ebeef5;padding-top:16px;">
|
|
<h4 style="font-size:14px;color:#303133;margin-bottom:8px;">
|
|
关键词详情:{{ selectedCampaign.name }}
|
|
<el-button size="small" type="primary" style="margin-left:8px;" @click="showKeywordDialog = true">添加关键词</el-button>
|
|
</h4>
|
|
<template v-if="campaignKeywords.length">
|
|
<el-table :data="campaignKeywords" border stripe class="data-table" style="width:100%">
|
|
<el-table-column prop="keyword" label="关键词" min-width="150"></el-table-column>
|
|
<el-table-column prop="search_volume" label="搜索量" width="80"></el-table-column>
|
|
<el-table-column label="难度" width="70">
|
|
<template #default="s"><el-tag :type="(s.row.difficulty || 0) > 0.7 ? 'danger' : (s.row.difficulty || 0) > 0.4 ? 'warning' : 'success'" size="small">{{ s.row.difficulty != null ? (s.row.difficulty * 100).toFixed(0) + '%' : '-' }}</el-tag></template>
|
|
</el-table-column>
|
|
<el-table-column prop="current_rank" label="当前排名" width="80"></el-table-column>
|
|
<el-table-column prop="target_rank" label="目标排名" width="80"></el-table-column>
|
|
<el-table-column prop="best_rank" label="最佳排名" width="80"></el-table-column>
|
|
<el-table-column label="操作" width="100">
|
|
<template #default="s">
|
|
<el-button size="small" type="danger" @click="deleteKeyword(s.row)">删除</el-button>
|
|
</template>
|
|
</el-table-column>
|
|
</el-table>
|
|
<div class="card-list-mobile">
|
|
<div v-for="kw in campaignKeywords" :key="kw.id" class="card-item">
|
|
<div class="card-row"><span class="card-label">关键词</span><span class="card-value">{{ kw.keyword }}</span></div>
|
|
<div class="card-row"><span class="card-label">搜索量</span><span class="card-value">{{ kw.search_volume || 0 }}</span></div>
|
|
<div class="card-row"><span class="card-label">难度</span><span class="card-value"><el-tag :type="(kw.difficulty || 0) > 0.7 ? 'danger' : (kw.difficulty || 0) > 0.4 ? 'warning' : 'success'" size="small">{{ kw.difficulty != null ? (kw.difficulty * 100).toFixed(0) + '%' : '-' }}</el-tag></span></div>
|
|
<div class="card-row"><span class="card-label">排名</span><span class="card-value">当前 {{ kw.current_rank || '-' }} 目标 {{ kw.target_rank || '-' }}</span></div>
|
|
<div class="card-actions">
|
|
<el-button size="small" type="danger" @click="deleteKeyword(kw)">删除</el-button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<div v-else style="text-align:center;padding:20px;color:#909399;font-size:13px;">暂无关键词数据</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="activeTab === 'rankings'">
|
|
<div class="toolbar">
|
|
<span style="font-size:13px;color:#909399;">
|
|
共 {{ keywordRankings.length }} 条记录
|
|
<span v-if="rankingOverview.with_rank != null"> · 有排名 {{ rankingOverview.with_rank }} 条</span>
|
|
</span>
|
|
</div>
|
|
<div v-if="loading" class="card-loading">加载中...</div>
|
|
<template v-else-if="keywordRankings.length === 0">
|
|
<div class="empty-state"><div class="empty-text">暂无排名数据,创建活动和关键词后将自动追踪</div></div>
|
|
</template>
|
|
<template v-else>
|
|
<el-table :data="keywordRankings" border stripe class="data-table" style="width:100%">
|
|
<el-table-column prop="keyword" label="关键词" min-width="140"></el-table-column>
|
|
<el-table-column label="搜索引擎" width="100">
|
|
<template #default="s">{{ engineLabel(s.row.search_engine) }}</template>
|
|
</el-table-column>
|
|
<el-table-column label="排名" width="80">
|
|
<template #default="s">
|
|
<el-tag :type="s.row.rank == null ? 'info' : s.row.rank <= 10 ? 'success' : s.row.rank <= 30 ? 'warning' : 'info'" size="small">{{ s.row.rank || '-' }}</el-tag>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column prop="url_found" label="排名URL" min-width="200">
|
|
<template #default="s"><a :href="s.row.url_found" target="_blank" style="color:#409eff;font-size:12px;">{{ s.row.url_found ? s.row.url_found.substring(0, 50) + '...' : '-' }}</a></template>
|
|
</el-table-column>
|
|
<el-table-column label="检查时间" width="150">
|
|
<template #default="s">{{ s.row.checked_at ? new Date(s.row.checked_at).toLocaleString('zh-CN') : '-' }}</template>
|
|
</el-table-column>
|
|
</el-table>
|
|
<div class="card-list-mobile">
|
|
<div v-for="r in keywordRankings" :key="r.id" class="card-item">
|
|
<div class="card-row"><span class="card-label">关键词</span><span class="card-value">{{ r.keyword }}</span></div>
|
|
<div class="card-row"><span class="card-label">引擎</span><span class="card-value">{{ engineLabel(r.search_engine) }}</span></div>
|
|
<div class="card-row"><span class="card-label">排名</span><span class="card-value"><el-tag :type="r.rank == null ? 'info' : r.rank <= 10 ? 'success' : r.rank <= 30 ? 'warning' : 'info'" size="small">{{ r.rank || '-' }}</el-tag></span></div>
|
|
<div class="card-row"><span class="card-label">时间</span><span class="card-value">{{ r.checked_at ? new Date(r.checked_at).toLocaleString('zh-CN') : '-' }}</span></div>
|
|
</div>
|
|
</div>
|
|
<div v-if="keywordRankings.length > pageSize" style="display:flex;justify-content:center;margin:12px 0;">
|
|
<el-pagination background layout="prev, pager, next" :total="keywordRankings.length" :page-size="pageSize" :current-page="rankingPage" @current-change="rankingPage = $event"></el-pagination>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<div v-if="activeTab === 'audits'">
|
|
<div class="toolbar">
|
|
<span style="font-size:13px;color:#909399;">共 {{ audits.length }} 条审计记录</span>
|
|
</div>
|
|
<div v-if="loading" class="card-loading">加载中...</div>
|
|
<template v-else-if="audits.length === 0">
|
|
<div class="empty-state"><div class="empty-text">暂无SEO审计记录</div></div>
|
|
</template>
|
|
<template v-else>
|
|
<el-table :data="audits" border stripe class="data-table" style="width:100%">
|
|
<el-table-column prop="product_name" label="产品" width="120"></el-table-column>
|
|
<el-table-column label="审计类型" width="80">
|
|
<template #default="s">{{ s.row.audit_type === 'full' ? '全面' : '快速' }}</template>
|
|
</el-table-column>
|
|
<el-table-column label="总分" width="80">
|
|
<template #default="s">
|
|
<el-tag :type="(s.row.overall_score || 0) >= 80 ? 'success' : (s.row.overall_score || 0) >= 60 ? 'warning' : 'danger'" size="small">{{ s.row.overall_score != null ? s.row.overall_score.toFixed(1) : '-' }}</el-tag>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column label="Meta" width="65"><template #default="s">{{ s.row.meta_score != null ? s.row.meta_score.toFixed(0) : '-' }}</template></el-table-column>
|
|
<el-table-column label="标题" width="65"><template #default="s">{{ s.row.heading_score != null ? s.row.heading_score.toFixed(0) : '-' }}</template></el-table-column>
|
|
<el-table-column label="内容" width="65"><template #default="s">{{ s.row.content_score != null ? s.row.content_score.toFixed(0) : '-' }}</template></el-table-column>
|
|
<el-table-column label="性能" width="65"><template #default="s">{{ s.row.perf_score != null ? s.row.perf_score.toFixed(0) : '-' }}</template></el-table-column>
|
|
<el-table-column label="链接" width="65"><template #default="s">{{ s.row.links_score != null ? s.row.links_score.toFixed(0) : '-' }}</template></el-table-column>
|
|
<el-table-column label="移动端" width="65"><template #default="s">{{ s.row.mobile_score != null ? s.row.mobile_score.toFixed(0) : '-' }}</template></el-table-column>
|
|
<el-table-column prop="issues_found" label="问题数" width="65"></el-table-column>
|
|
<el-table-column label="审计时间" width="150">
|
|
<template #default="s">{{ s.row.checked_at ? new Date(s.row.checked_at).toLocaleString('zh-CN') : '-' }}</template>
|
|
</el-table-column>
|
|
</el-table>
|
|
<div class="card-list-mobile">
|
|
<div v-for="a in audits" :key="a.id" class="card-item">
|
|
<div class="card-row"><span class="card-label">产品</span><span class="card-value">{{ a.product_name || '-' }}</span></div>
|
|
<div class="card-row"><span class="card-label">总分</span><span class="card-value"><el-tag :type="(a.overall_score || 0) >= 80 ? 'success' : (a.overall_score || 0) >= 60 ? 'warning' : 'danger'" size="small">{{ a.overall_score != null ? a.overall_score.toFixed(1) : '-' }}</el-tag></span></div>
|
|
<div class="card-row"><span class="card-label">评分</span><span class="card-value" style="font-size:12px;">M{{ a.meta_score || '-' }} H{{ a.heading_score || '-' }} C{{ a.content_score || '-' }} P{{ a.perf_score || '-' }} L{{ a.links_score || '-' }} Mob{{ a.mobile_score || '-' }}</span></div>
|
|
<div class="card-row"><span class="card-label">问题数</span><span class="card-value">{{ a.issues_found || 0 }}</span></div>
|
|
<div class="card-row"><span class="card-label">时间</span><span class="card-value">{{ a.checked_at ? new Date(a.checked_at).toLocaleString('zh-CN') : '-' }}</span></div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<div v-if="activeTab === 'tasks'">
|
|
<div class="toolbar">
|
|
<el-button :type="taskFilter === 'open' ? 'primary' : ''" size="small" @click="taskFilter = 'open'; loadTasks()">待处理</el-button>
|
|
<el-button :type="taskFilter === 'resolved' ? 'primary' : ''" size="small" @click="taskFilter = 'resolved'; loadTasks()">已解决</el-button>
|
|
<el-button :type="taskFilter === null ? 'primary' : ''" size="small" @click="taskFilter = null; loadTasks()">全部</el-button>
|
|
</div>
|
|
<div v-if="loading" class="card-loading">加载中...</div>
|
|
<template v-else-if="optimizationTasks.length === 0">
|
|
<div class="empty-state"><div class="empty-text">暂无优化建议</div></div>
|
|
</template>
|
|
<template v-else>
|
|
<el-table :data="optimizationTasks" border stripe class="data-table" style="width:100%">
|
|
<el-table-column label="严重度" width="80">
|
|
<template #default="s">
|
|
<el-tag :type="s.row.severity === 'high' ? 'danger' : s.row.severity === 'medium' ? 'warning' : 'info'" size="small">{{ { high: '高', medium: '中', low: '低' }[s.row.severity] || s.row.severity }}</el-tag>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column label="分类" width="90">
|
|
<template #default="s">{{ { meta: 'Meta', heading: '标题', content: '内容', performance: '性能', links: '链接', mobile: '移动端' }[s.row.category] || s.row.category }}</template>
|
|
</el-table-column>
|
|
<el-table-column prop="issue" label="问题" min-width="200"></el-table-column>
|
|
<el-table-column prop="recommendation" label="建议" min-width="200"></el-table-column>
|
|
<el-table-column label="状态" width="90">
|
|
<template #default="s">
|
|
<el-tag :type="s.row.status === 'resolved' ? 'success' : s.row.status === 'ignored' ? 'info' : 'warning'" size="small">{{ { open: '待处理', resolved: '已解决', ignored: '已忽略' }[s.row.status] || s.row.status }}</el-tag>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column label="操作" width="120" fixed="right">
|
|
<template #default="s">
|
|
<el-button v-if="s.row.status === 'open'" size="small" type="success" @click="updateTaskStatus(s.row, 'resolved')">解决</el-button>
|
|
<el-button size="small" @click="updateTaskStatus(s.row, 'ignored')">忽略</el-button>
|
|
</template>
|
|
</el-table-column>
|
|
</el-table>
|
|
<div class="card-list-mobile">
|
|
<div v-for="t in optimizationTasks" :key="t.id" class="card-item">
|
|
<div class="card-row"><span class="card-label">严重度</span><span class="card-value"><el-tag :type="t.severity === 'high' ? 'danger' : t.severity === 'medium' ? 'warning' : 'info'" size="small">{{ { high: '高', medium: '中', low: '低' }[t.severity] || t.severity }}</el-tag></span></div>
|
|
<div class="card-row"><span class="card-label">分类</span><span class="card-value">{{ { meta: 'Meta', heading: '标题', content: '内容', performance: '性能', links: '链接', mobile: '移动端' }[t.category] || t.category }}</span></div>
|
|
<div class="card-row"><span class="card-label">问题</span><span class="card-value" style="font-size:12px;">{{ t.issue }}</span></div>
|
|
<div class="card-row"><span class="card-label">建议</span><span class="card-value" style="font-size:12px;">{{ t.recommendation }}</span></div>
|
|
<div class="card-row"><span class="card-label">状态</span><span class="card-value"><el-tag :type="t.status === 'resolved' ? 'success' : t.status === 'ignored' ? 'info' : 'warning'" size="small">{{ { open: '待处理', resolved: '已解决', ignored: '已忽略' }[t.status] || t.status }}</el-tag></span></div>
|
|
<div class="card-actions">
|
|
<el-button v-if="t.status === 'open'" size="small" type="success" @click="updateTaskStatus(t, 'resolved')">解决</el-button>
|
|
<el-button size="small" @click="updateTaskStatus(t, 'ignored')">忽略</el-button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
</div>
|
|
|
|
<!-- Product Dialog -->
|
|
<el-dialog v-model="showProductDialog" :title="editingProduct ? '编辑产品' : '新增产品'" width="500px">
|
|
<el-form label-width="100px">
|
|
<el-form-item label="产品名称"><el-input v-model="productForm.name" placeholder="例: 我的个人博客"></el-input></el-form-item>
|
|
<el-form-item label="产品类型">
|
|
<el-select v-model="productForm.type" style="width:100%">
|
|
<el-option label="网站" value="website"></el-option>
|
|
<el-option label="文章" value="article"></el-option>
|
|
<el-option label="公众号" value="wechat_account"></el-option>
|
|
<el-option label="小程序" value="miniprogram"></el-option>
|
|
</el-select>
|
|
</el-form-item>
|
|
<el-form-item label="URL"><el-input v-model="productForm.url" placeholder="https://example.com"></el-input></el-form-item>
|
|
<el-form-item label="域名"><el-input v-model="productForm.domain" placeholder="example.com"></el-input></el-form-item>
|
|
<el-form-item v-if="productForm.type === 'miniprogram'" label="AppID"><el-input v-model="productForm.app_id" placeholder="微信小程序AppID"></el-input></el-form-item>
|
|
<el-form-item v-if="productForm.type === 'wechat_account'" label="公众号ID"><el-input v-model="productForm.account_id" placeholder="公众号ID"></el-input></el-form-item>
|
|
<el-form-item label="描述"><el-input v-model="productForm.description" type="textarea" :rows="3"></el-input></el-form-item>
|
|
</el-form>
|
|
<template #footer>
|
|
<el-button @click="showProductDialog = false">取消</el-button>
|
|
<el-button type="primary" @click="saveProduct">{{ editingProduct ? '保存' : '创建' }}</el-button>
|
|
</template>
|
|
</el-dialog>
|
|
|
|
<!-- Campaign Dialog -->
|
|
<el-dialog v-model="showCampaignDialog" :title="editingCampaign ? '编辑活动' : '新增活动'" width="500px">
|
|
<el-form label-width="100px">
|
|
<el-form-item label="活动名称"><el-input v-model="campaignForm.name" placeholder="例: 百度SEO推广"></el-input></el-form-item>
|
|
<el-form-item label="关联产品">
|
|
<el-select v-model="campaignForm.product_id" style="width:100%" placeholder="选择推广产品">
|
|
<el-option v-for="p in products" :key="p.id" :label="p.name" :value="p.id"></el-option>
|
|
</el-select>
|
|
</el-form-item>
|
|
<el-form-item label="状态">
|
|
<el-select v-model="campaignForm.status" style="width:100%">
|
|
<el-option label="进行中" value="active"></el-option>
|
|
<el-option label="已暂停" value="paused"></el-option>
|
|
<el-option label="已完成" value="completed"></el-option>
|
|
</el-select>
|
|
</el-form-item>
|
|
<el-form-item label="目标引擎">
|
|
<el-checkbox-group v-model="campaignForm.target_engines">
|
|
<el-checkbox label="baidu">百度</el-checkbox>
|
|
<el-checkbox label="360">360</el-checkbox>
|
|
<el-checkbox label="sogou">搜狗</el-checkbox>
|
|
<el-checkbox label="wechat">微信搜索</el-checkbox>
|
|
<el-checkbox label="bing">Bing</el-checkbox>
|
|
</el-checkbox-group>
|
|
</el-form-item>
|
|
<el-form-item label="关键词">
|
|
<el-select v-model="campaignForm.keywords" multiple filterable allow-create default-first-option style="width:100%" placeholder="输入关键词后回车添加">
|
|
</el-select>
|
|
</el-form-item>
|
|
<el-form-item label="备注"><el-input v-model="campaignForm.notes" type="textarea" :rows="2"></el-input></el-form-item>
|
|
</el-form>
|
|
<template #footer>
|
|
<el-button @click="showCampaignDialog = false">取消</el-button>
|
|
<el-button type="primary" @click="saveCampaign">{{ editingCampaign ? '保存' : '创建' }}</el-button>
|
|
</template>
|
|
</el-dialog>
|
|
|
|
<!-- Keyword Dialog -->
|
|
<el-dialog v-model="showKeywordDialog" title="添加关键词" width="450px">
|
|
<el-form label-width="90px">
|
|
<el-form-item label="关键词"><el-input v-model="keywordForm.keyword" placeholder="输入关键词"></el-input></el-form-item>
|
|
<el-form-item label="搜索量"><el-input-number v-model="keywordForm.search_volume" :min="0" style="width:100%"></el-input-number></el-form-item>
|
|
<el-form-item label="竞争难度"><el-slider v-model="keywordForm.difficulty" :min="0" :max="1" :step="0.05" show-input style="width:100%"></el-slider></el-form-item>
|
|
<el-form-item label="目标排名"><el-input-number v-model="keywordForm.target_rank" :min="1" :max="100" style="width:100%"></el-input-number></el-form-item>
|
|
</el-form>
|
|
<template #footer>
|
|
<el-button @click="showKeywordDialog = false">取消</el-button>
|
|
<el-button type="primary" @click="saveKeyword">添加</el-button>
|
|
</template>
|
|
</el-dialog>
|
|
</div>
|
|
|
|
<script>
|
|
const { createApp, ref, computed, onMounted, nextTick } = Vue;
|
|
const { ElMessage } = ElementPlus;
|
|
|
|
// Auth check — redirect early if no token
|
|
const authToken = localStorage.getItem('authToken') || '';
|
|
if (!authToken) { window.location.href = '/login.html'; }
|
|
|
|
const app = createApp({
|
|
data() {
|
|
return {
|
|
currentUser: {},
|
|
isAdmin: false,
|
|
activeTab: 'overview',
|
|
|
|
loading: false,
|
|
pageSize: 10, productPage: 1, campaignPage: 1, rankingPage: 1,
|
|
|
|
overview: {},
|
|
products: [],
|
|
campaigns: [],
|
|
selectedCampaign: null,
|
|
campaignKeywords: [],
|
|
keywordRankings: [],
|
|
keywordRankingOverview: {},
|
|
audits: [],
|
|
optimizationTasks: [],
|
|
taskFilter: 'open',
|
|
|
|
showProductDialog: false,
|
|
editingProduct: null,
|
|
productForm: { name: '', type: 'website', url: '', domain: '', app_id: '', account_id: '', description: '' },
|
|
|
|
showCampaignDialog: false,
|
|
editingCampaign: null,
|
|
campaignForm: { name: '', product_id: null, status: 'active', target_engines: [], keywords: [], notes: '' },
|
|
|
|
showKeywordDialog: false,
|
|
keywordForm: { keyword: '', search_volume: 0, difficulty: 0.5, target_rank: 10 },
|
|
|
|
token: authToken,
|
|
};
|
|
},
|
|
computed: {
|
|
paginatedProducts() {
|
|
const start = (this.productPage - 1) * this.pageSize;
|
|
return this.products.slice(start, start + this.pageSize);
|
|
},
|
|
paginatedCampaigns() {
|
|
const start = (this.campaignPage - 1) * this.pageSize;
|
|
return this.campaigns.slice(start, start + this.pageSize);
|
|
},
|
|
paginatedRankings() {
|
|
const start = (this.rankingPage - 1) * this.pageSize;
|
|
return this.keywordRankings.slice(start, start + this.pageSize);
|
|
},
|
|
},
|
|
methods: {
|
|
typeLabel(t) {
|
|
return { website: '网站', article: '文章', wechat_account: '公众号', miniprogram: '小程序' }[t] || t;
|
|
},
|
|
engineLabel(e) {
|
|
return { baidu: '百度', bing: 'Bing', sogou: '搜狗', '360': '360', wechat: '微信搜索' }[e] || e;
|
|
},
|
|
api(path) { return '/api/external' + path; },
|
|
headers() { return { 'Authorization': 'Bearer ' + this.token, 'Content-Type': 'application/json' }; },
|
|
async fetchJson(url, opts) {
|
|
const r = await fetch(url, opts || { headers: this.headers() });
|
|
const d = await r.json();
|
|
if (!d.ok) throw new Error(d.detail || '请求失败');
|
|
return d.data;
|
|
},
|
|
|
|
switchTab(tab) {
|
|
this.activeTab = tab;
|
|
this.selectedCampaign = null;
|
|
this.loadTabData();
|
|
},
|
|
async loadTabData() {
|
|
this.loading = true;
|
|
try {
|
|
if (this.activeTab === 'overview') {
|
|
this.overview = await this.fetchJson(this.api('/overview'));
|
|
} else if (this.activeTab === 'products') {
|
|
this.products = await this.fetchJson(this.api('/products'));
|
|
} else if (this.activeTab === 'campaigns') {
|
|
this.campaigns = await this.fetchJson(this.api('/campaigns'));
|
|
} else if (this.activeTab === 'rankings') {
|
|
await this.loadRankings();
|
|
} else if (this.activeTab === 'audits') {
|
|
this.audits = await this.fetchJson(this.api('/audits'));
|
|
} else if (this.activeTab === 'tasks') {
|
|
await this.loadTasks();
|
|
}
|
|
} catch (e) {
|
|
ElMessage.error(e.message);
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
},
|
|
async loadRankings() {
|
|
this.keywordRankings = await this.fetchJson(this.api('/rankings'));
|
|
try { this.keywordRankingOverview = await this.fetchJson(this.api('/rankings/overview')); } catch (e) {}
|
|
},
|
|
async loadTasks() {
|
|
let url = this.api('/optimization-tasks');
|
|
if (this.taskFilter) url += '?status=' + this.taskFilter;
|
|
this.optimizationTasks = await this.fetchJson(url);
|
|
},
|
|
|
|
// Products
|
|
editProduct(p) {
|
|
this.editingProduct = p;
|
|
this.productForm = { ...p };
|
|
this.showProductDialog = true;
|
|
},
|
|
async saveProduct() {
|
|
const url = this.editingProduct
|
|
? this.api('/products/' + this.editingProduct.id)
|
|
: this.api('/products');
|
|
const method = this.editingProduct ? 'PUT' : 'POST';
|
|
const r = await fetch(url, { method, headers: this.headers(), body: JSON.stringify(this.productForm) });
|
|
const d = await r.json();
|
|
if (!d.ok) throw new Error(d.detail || '操作失败');
|
|
ElMessage.success(this.editingProduct ? '已更新' : '已创建');
|
|
this.showProductDialog = false;
|
|
this.editingProduct = null;
|
|
this.productForm = { name: '', type: 'website', url: '', domain: '', app_id: '', account_id: '', description: '' };
|
|
this.products = await this.fetchJson(this.api('/products'));
|
|
},
|
|
async deleteProduct(p) {
|
|
if (!confirm('确定删除产品「' + p.name + '」?')) return;
|
|
await this.fetchJson(this.api('/products/' + p.id), { method: 'DELETE', headers: this.headers() });
|
|
ElMessage.success('已删除');
|
|
this.products = await this.fetchJson(this.api('/products'));
|
|
},
|
|
|
|
// Campaigns
|
|
viewCampaignKeywords(c) {
|
|
this.selectedCampaign = c;
|
|
this.loadCampaignKeywords(c.id);
|
|
},
|
|
async loadCampaignKeywords(campaignId) {
|
|
try {
|
|
this.campaignKeywords = await this.fetchJson(this.api('/keywords?campaign_id=' + campaignId));
|
|
} catch (e) {
|
|
this.campaignKeywords = [];
|
|
}
|
|
},
|
|
editCampaign(c) {
|
|
this.editingCampaign = c;
|
|
this.campaignForm = { name: c.name, product_id: c.product_id, status: c.status, target_engines: c.target_engines || [], keywords: c.keywords || [], notes: c.notes || '' };
|
|
this.showCampaignDialog = true;
|
|
},
|
|
async saveCampaign() {
|
|
const url = this.editingCampaign
|
|
? this.api('/campaigns/' + this.editingCampaign.id)
|
|
: this.api('/campaigns');
|
|
const method = this.editingCampaign ? 'PUT' : 'POST';
|
|
const r = await fetch(url, { method, headers: this.headers(), body: JSON.stringify(this.campaignForm) });
|
|
const d = await r.json();
|
|
if (!d.ok) throw new Error(d.detail || '操作失败');
|
|
ElMessage.success(this.editingCampaign ? '已更新' : '已创建');
|
|
this.showCampaignDialog = false;
|
|
this.editingCampaign = null;
|
|
this.campaignForm = { name: '', product_id: null, status: 'active', target_engines: [], keywords: [], notes: '' };
|
|
this.campaigns = await this.fetchJson(this.api('/campaigns'));
|
|
},
|
|
async deleteCampaign(c) {
|
|
if (!confirm('确定删除活动「' + c.name + '」?')) return;
|
|
await this.fetchJson(this.api('/campaigns/' + c.id), { method: 'DELETE', headers: this.headers() });
|
|
ElMessage.success('已删除');
|
|
this.campaigns = await this.fetchJson(this.api('/campaigns'));
|
|
},
|
|
|
|
// Keywords
|
|
async saveKeyword() {
|
|
if (!this.selectedCampaign) { ElMessage.warning('请先选择活动'); return; }
|
|
this.keywordForm.campaign_id = this.selectedCampaign.id;
|
|
const r = await fetch(this.api('/keywords'), { method: 'POST', headers: this.headers(), body: JSON.stringify(this.keywordForm) });
|
|
const d = await r.json();
|
|
if (!d.ok) throw new Error(d.detail || '添加失败');
|
|
ElMessage.success('已添加');
|
|
this.showKeywordDialog = false;
|
|
this.keywordForm = { keyword: '', search_volume: 0, difficulty: 0.5, target_rank: 10 };
|
|
this.loadCampaignKeywords(this.selectedCampaign.id);
|
|
},
|
|
async deleteKeyword(kw) {
|
|
if (!confirm('确定删除关键词「' + kw.keyword + '」?')) return;
|
|
await this.fetchJson(this.api('/keywords/' + kw.id), { method: 'DELETE', headers: this.headers() });
|
|
ElMessage.success('已删除');
|
|
if (this.selectedCampaign) this.loadCampaignKeywords(this.selectedCampaign.id);
|
|
},
|
|
|
|
// Optimization Tasks
|
|
async updateTaskStatus(task, status) {
|
|
await this.fetchJson(this.api('/optimization-tasks/' + task.id), { method: 'PATCH', headers: this.headers(), body: JSON.stringify({ status: status }) });
|
|
ElMessage.success('已更新');
|
|
this.loadTasks();
|
|
},
|
|
|
|
redirectToPage(page) {
|
|
window.location.href = '/' + page;
|
|
},
|
|
logout() {
|
|
localStorage.removeItem('authToken');
|
|
window.location.href = '/login.html';
|
|
},
|
|
},
|
|
mounted() {
|
|
const token = this.token;
|
|
if (!token) { window.location.href = '/login.html'; return; }
|
|
fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token } })
|
|
.then(function (r) { if (!r.ok) throw new Error('unauthorized'); return r.json(); })
|
|
.then(function (d) {
|
|
this.currentUser = d.user || d;
|
|
this.isAdmin = this.currentUser.role === 'admin';
|
|
}.bind(this))
|
|
.catch(function () { window.location.href = '/login.html'; });
|
|
this.loadTabData();
|
|
},
|
|
});
|
|
|
|
app.use(ElementPlus);
|
|
if (window.installIcons) { window.installIcons(app); }
|
|
if (window.installUniNav) { window.installUniNav(app); }
|
|
app.mount('#app');
|
|
</script>
|
|
</body>
|
|
</html>
|