feat: GEO/SEO structured data + search ranking tracker
This commit is contained in:
@@ -32,6 +32,7 @@
|
||||
<el-button size="default" :type="activeTab === 'logs' ? 'primary' : ''" @click="switchTab('logs')">运行日志</el-button>
|
||||
<el-button size="default" :type="activeTab === 'assistant' ? 'primary' : ''" @click="switchTab('assistant')">AI 助手</el-button>
|
||||
<el-button size="default" :type="activeTab === 'searchproviders' ? 'primary' : ''" @click="switchTab('searchproviders')">搜索API</el-button>
|
||||
<el-button size="default" :type="activeTab === 'searchrankings' ? 'primary' : ''" @click="switchTab('searchrankings')">搜索排名</el-button>
|
||||
<el-button size="default" :type="activeTab === 'configitems' ? 'primary' : ''" @click="switchTab('configitems')">配置管理</el-button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -607,6 +608,53 @@
|
||||
<template #footer><el-button @click="searchProviderTestVisible = false">关闭</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<div v-if="activeTab === 'searchrankings'">
|
||||
<div class="toolbar">
|
||||
<span style="font-size:13px;color:#909399;">GEO/SEO 搜索排名追踪 — 每日 07:00 自动检查</span>
|
||||
</div>
|
||||
<div v-if="rankingsLoading" class="card-loading">加载中...</div>
|
||||
<template v-else>
|
||||
<div style="display:flex;gap:16px;margin-bottom:16px;flex-wrap:wrap;">
|
||||
<div class="stat-card" style="flex:1;min-width:140px;padding:16px;background:#f0f9ff;border-radius:8px;text-align:center;">
|
||||
<div style="font-size:28px;font-weight:700;color:#409eff;">{{ rankingStats.total_checks }}</div>
|
||||
<div style="font-size:12px;color:#909399;margin-top:4px;">累计检查关键词</div>
|
||||
</div>
|
||||
<div class="stat-card" style="flex:1;min-width:140px;padding:16px;background:#f0fdf4;border-radius:8px;text-align:center;">
|
||||
<div style="font-size:28px;font-weight:700;color:#67c23a;">{{ rankingStats.on_page }}</div>
|
||||
<div style="font-size:12px;color:#909399;margin-top:4px;">有排名关键词</div>
|
||||
</div>
|
||||
<div class="stat-card" style="flex:1;min-width:140px;padding:16px;background:#fef3f2;border-radius:8px;text-align:center;">
|
||||
<div style="font-size:28px;font-weight:700;color:#f56c6c;">{{ rankingStats.ai_cited }}</div>
|
||||
<div style="font-size:12px;color:#909399;margin-top:4px;">AI 搜索引用</div>
|
||||
</div>
|
||||
<div class="stat-card" style="flex:1;min-width:140px;padding:16px;background:#fff7e6;border-radius:8px;text-align:center;">
|
||||
<div style="font-size:28px;font-weight:700;color:#e6a23c;">{{ rankingStats.best_position ? '#' + rankingStats.best_position : '-' }}</div>
|
||||
<div style="font-size:12px;color:#909399;margin-top:4px;">最佳排名</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table :data="rankings" style="width:100%" size="small" max-height="500">
|
||||
<el-table-column prop="keyword" label="关键词" min-width="140" />
|
||||
<el-table-column prop="platform" label="平台" width="80" />
|
||||
<el-table-column prop="search_engine" label="引擎" width="70" />
|
||||
<el-table-column prop="position" label="排名" width="70">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.position && row.position <= 3 ? 'success' : row.position ? 'warning' : 'info'" size="small">
|
||||
{{ row.position ? '#' + row.position : '未上榜' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="ai_cited" label="AI引用" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.ai_cited ? 'success' : 'info'" size="small">{{ row.ai_cited ? '是' : '否' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="url_found" label="匹配 URL" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="checked_at" label="检查时间" width="160" />
|
||||
</el-table>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'configitems'">
|
||||
<div class="filter-bar">
|
||||
<el-button size="default" :type="configSubTab === 'sensitive' ? 'primary' : ''" @click="switchConfigSubTab('sensitive')">🔒 敏感词</el-button>
|
||||
@@ -634,7 +682,7 @@
|
||||
<el-tag size="small">{{ row.category || '未分类' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120">
|
||||
<el-table-column label="操作" width="150">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="editSensitiveWord(row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteSensitiveWord(row)">删除</el-button>
|
||||
@@ -682,7 +730,7 @@
|
||||
<el-tag :type="row.is_active ? 'success' : 'info'" size="small">{{ row.is_active ? '启用' : '停用' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120">
|
||||
<el-table-column label="操作" width="150">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="editCleanRule(row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteCleanRule(row)">删除</el-button>
|
||||
@@ -1270,6 +1318,20 @@ const llmConfigs = ref([]);
|
||||
return Object.entries(map).sort((a, b) => a[0].localeCompare(b[0]));
|
||||
});
|
||||
|
||||
const rankings = ref([]);
|
||||
const rankingStats = ref({ total_checks: 0, on_page: 0, ai_cited: 0, best_position: null });
|
||||
const rankingsLoading = ref(false);
|
||||
const loadRankings = async () => {
|
||||
rankingsLoading.value = true;
|
||||
try {
|
||||
const resp = await api.get('/api/seo/rankings?limit=100');
|
||||
rankings.value = resp.data || [];
|
||||
const overview = await api.get('/api/seo/rankings/overview');
|
||||
rankingStats.value = overview.data || { total_checks: 0, on_page: 0, ai_cited: 0, best_position: null };
|
||||
} catch (e) { console.error('加载排名数据失败:', e); }
|
||||
finally { rankingsLoading.value = false; }
|
||||
};
|
||||
|
||||
const contentCleanRules = ref([]);
|
||||
const ccrLoading = ref(false);
|
||||
const ccrDialogVisible = ref(false);
|
||||
@@ -1319,6 +1381,7 @@ const llmConfigs = ref([]);
|
||||
users: fetchUsers,
|
||||
logs: loadLogTypes, orgs: loadOrgs, roles: loadRoles, menus: loadMenus, assistant: loadAssistantConfig,
|
||||
searchproviders: loadSearchProviders,
|
||||
searchrankings: loadRankings,
|
||||
configitems: () => { loadSensitiveWords(); loadContentCleanRules(); },
|
||||
};
|
||||
const loadedTabs = new Set([]);
|
||||
@@ -1378,6 +1441,7 @@ const llmConfigs = ref([]);
|
||||
sensitiveWords, swLoading, swDialogVisible, swSaving, swForm, editingSwId, loadSensitiveWords, showAddSensitiveWord, editSensitiveWord, saveSensitiveWord, deleteSensitiveWord, configSubTab, switchConfigSubTab,
|
||||
contentCleanRules, ccrLoading, ccrDialogVisible, ccrDialogTitle, ccrSaving, ccrForm, editingCcrId,
|
||||
loadContentCleanRules, showAddCleanRule, editCleanRule, saveCleanRule, deleteCleanRule,
|
||||
rankings, rankingStats, rankingsLoading, loadRankings,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
@@ -124,8 +124,8 @@
|
||||
<el-button v-if="previewFullscreen" size="small" type="danger" @click="previewVisible = false">关闭</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="max-width: 1000px; margin: 0 auto; width: 100%; height: 100%; display: flex; flex-direction: column;">
|
||||
<iframe v-if="previewArticleData.html_content" :srcdoc="previewArticleData.html_content" class="preview-iframe" style="flex:1; min-height:500px; border:1px solid #ebeef5; border-radius:8px; background:#fff; overflow:auto; padding:0; width:100%;" sandbox></iframe>
|
||||
<div style="max-width: 1000px; margin: 0 auto; width: 100%; flex: 1; min-height: 0; display: flex; flex-direction: column;">
|
||||
<iframe v-if="previewArticleData.html_content" :srcdoc="previewArticleData.html_content" class="preview-iframe" style="flex:1; border:1px solid #ebeef5; border-radius:8px; background:#fff; padding:0;" sandbox></iframe>
|
||||
<div v-else style="padding:60px 20px; text-align:center; color:#909399; font-size:14px;">该文章暂无 HTML 内容</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -363,13 +363,13 @@ body {
|
||||
.login-footer a:hover { color: var(--color-accent); }
|
||||
|
||||
/* ========== Preview Dialog ========== */
|
||||
.preview-iframe { box-sizing: border-box; }
|
||||
.preview-iframe { box-sizing: border-box; height: 100%; width: 100%; }
|
||||
.preview-dialog-custom.el-dialog {
|
||||
max-height: calc(100vh - 90px); overflow: hidden; display: flex;
|
||||
flex-direction: column; margin-top: 0 !important;
|
||||
}
|
||||
.preview-dialog-custom.el-dialog .el-dialog__header { padding: var(--spacing-sm) var(--spacing-md); margin: 0; flex-shrink: 0; }
|
||||
.preview-dialog-custom.el-dialog .el-dialog__body { padding: var(--spacing-md); overflow: hidden; }
|
||||
.preview-dialog-custom.el-dialog .el-dialog__body { padding: var(--spacing-md); overflow: hidden; flex: 1; min-height: 0; }
|
||||
.preview-dialog-custom.el-dialog .el-dialog__footer { flex-shrink: 0; padding: var(--spacing-sm) var(--spacing-md); }
|
||||
.preview-dialog-custom.is-fullscreen { z-index: 100001 !important; }
|
||||
body:has(.preview-dialog-custom.is-fullscreen) > [class*="el-overlay"] { z-index: 100000 !important; }
|
||||
@@ -400,11 +400,11 @@ body:has(.preview-dialog-custom.is-fullscreen) > [class*="el-overlay"] { z-index
|
||||
.el-button--default { padding: 10px 16px !important; }
|
||||
.el-input__inner { min-height: 38px; }
|
||||
|
||||
.preview-iframe { max-height: calc(100vh - 250px) !important; }
|
||||
.preview-iframe { min-height: 300px; }
|
||||
.preview-dialog-custom { position: relative; }
|
||||
}
|
||||
@media (min-width: 769px) {
|
||||
.preview-iframe { max-height: calc(100vh - 100px) !important; }
|
||||
.preview-iframe { min-height: 400px; }
|
||||
.preview-dialog-custom { position: relative; left: 90px; }
|
||||
}
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
<link rel="stylesheet" href="theme-modern.css">
|
||||
<style>
|
||||
.selected-count { color: var(--color-text-secondary); font-size: var(--font-size-body); margin-left: auto; }
|
||||
.preview-dialog-custom .el-dialog__body { overflow-y: auto; max-height: calc(90vh - 120px); min-height: 400px; padding: 16px 20px; }
|
||||
.preview-dialog-custom .preview-body { max-height: calc(90vh - 180px); min-height: 300px; overflow: auto; }
|
||||
.preview-dialog-custom.el-dialog .el-dialog__body { overflow-y: auto; min-height: 400px; padding: 16px 20px; }
|
||||
.preview-dialog-custom .preview-body { min-height: 300px; overflow: auto; }
|
||||
.preview-dialog-custom .preview-body img { max-width: 100%; }
|
||||
.topic-card-list { display: none; }
|
||||
@media (max-width: 768px) {
|
||||
@@ -41,10 +41,11 @@
|
||||
<div class="page-header">
|
||||
<h2 class="page-title"><el-icon style="vertical-align:-2px;"><IconTopic /></el-icon> 选题管理</h2>
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" size="small" @click="refreshAll"><el-icon style="vertical-align:-2px;"><IconRefresh /></el-icon> 批量刷新</el-button>
|
||||
<el-button type="primary" size="small" @click="openCreateDialog"><el-icon style="vertical-align:-2px;"><IconPlus /></el-icon> 新增选题</el-button>
|
||||
<el-button size="small" @click="refreshAll"><el-icon style="vertical-align:-2px;"><IconRefresh /></el-icon> 批量刷新</el-button>
|
||||
<el-button size="small" @click="toggleSelectAll">{{ selectAllLabel }}</el-button>
|
||||
<el-button type="success" size="small" @click="triggerGenerateSelected" :disabled="selectedTopicIds.length === 0"><el-icon style="vertical-align:-2px;"><IconPlus /></el-icon> 批量创作</el-button>
|
||||
<el-button type="warning" size="small" @click="triggerReviewSelected" :disabled="selectedTopicIds.length === 0"><el-icon style="vertical-align:-2px;"><IconSearch /></el-icon> 批量审查</el-button>
|
||||
<el-button type="success" size="small" @click="triggerGenerateSelected" :disabled="selectedTopicIds.length === 0"> 批量创作</el-button>
|
||||
<el-button type="warning" size="small" @click="triggerReviewSelected" :disabled="selectedTopicIds.length === 0"> 批量审查</el-button>
|
||||
<span v-if="selectedTopicIds.length > 0" class="selected-count">已选 {{ selectedTopicIds.length }} 项</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -162,6 +163,82 @@
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<el-dialog v-model="createDialogVisible" :title="createStep === 1 ? '新增选题' : 'AI 分析结果'" width="680px" :close-on-click-modal="false" @close="resetCreateDialog">
|
||||
<template v-if="createStep === 1">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="选题思路 / 内容描述">
|
||||
<el-input type="textarea" :rows="5" v-model="createForm.raw_input" placeholder="描述你想写的选题方向,可以是大致想法、关键词、或者一句话需求 例如:想写一篇关于旧物换补贴的实操指南,结合最近的碳普惠政策" />
|
||||
</el-form-item>
|
||||
<el-form-item label="参考链接(可选,每行一个)">
|
||||
<el-input type="textarea" :rows="3" v-model="createForm.reference_links" placeholder="https://... https://..." />
|
||||
</el-form-item>
|
||||
<el-form-item label="所属领域(可选)">
|
||||
<el-select v-model="createForm.field_id" placeholder="选择领域" clearable style="width:100%;">
|
||||
<el-option v-for="f in fieldOptions" :key="f.id" :label="f.name" :value="f.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="选题标题">
|
||||
<el-input v-model="createResult.title" />
|
||||
</el-form-item>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="内容形式">
|
||||
<el-select v-model="createResult.format" style="width:100%;">
|
||||
<el-option label="趋势洞察" value="趋势洞察" />
|
||||
<el-option label="实操指南" value="实操指南" />
|
||||
<el-option label="对比分析" value="对比分析" />
|
||||
<el-option label="案例解读" value="案例解读" />
|
||||
<el-option label="观点讨论" value="观点讨论" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="优先级">
|
||||
<el-select v-model="createResult.priority" style="width:100%;">
|
||||
<el-option label="高" value="高" />
|
||||
<el-option label="中" value="中" />
|
||||
<el-option label="低" value="低" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="核心概念">
|
||||
<el-input v-model="createResult.core_concept" />
|
||||
</el-form-item>
|
||||
<el-form-item label="受众痛点">
|
||||
<el-input v-model="createResult.audience_pain" />
|
||||
</el-form-item>
|
||||
<el-form-item label="独特角度">
|
||||
<el-input v-model="createResult.unique_angle" />
|
||||
</el-form-item>
|
||||
<el-form-item label="标签(回车添加)">
|
||||
<el-select v-model="createResult.tags" multiple filterable allow-create default-first-option style="width:100%;" placeholder="输入标签后回车添加">
|
||||
<el-option v-for="t in createResult.tags" :key="t" :label="t" :value="t" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div style="display:flex; justify-content:space-between; width:100%;">
|
||||
<div>
|
||||
<el-button v-if="createStep === 2" size="small" @click="createStep = 1">← 返回编辑输入</el-button>
|
||||
<el-button v-else @click="createDialogVisible = false">取消</el-button>
|
||||
</div>
|
||||
<div v-if="createStep === 1">
|
||||
<el-button type="primary" @click="runAiAnalysis" :loading="analyzing">AI 智能分析</el-button>
|
||||
</div>
|
||||
<div v-else>
|
||||
<el-button @click="saveCreatedTopic('list')">仅保存到选题列表</el-button>
|
||||
<el-button type="success" @click="saveCreatedTopic('create')" :loading="saving">保存并创作</el-button>
|
||||
<el-button type="warning" @click="saveCreatedTopic('review')" :loading="saving">保存并审查</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
<el-dialog v-model="publishDialogVisible" title="发布确认" width="420px" :close-on-click-modal="false">
|
||||
<div v-if="publishTopic">
|
||||
<div style="margin-bottom:16px;">
|
||||
@@ -253,10 +330,14 @@ const TopicsApp = {
|
||||
publishPlatforms: { zhihu: true, wechat: true, xiaohongshu: true },
|
||||
publishing: false,
|
||||
savingContent: false,
|
||||
currentPage: 1, pageSize: 10,
|
||||
sortField: 'created_at', sortOrder: 'ascending',
|
||||
showSearch: false,
|
||||
searchForm: { id: '', title: '', field: '', createdStart: null, createdEnd: null, generatedStart: null, generatedEnd: null, reviewedStart: null, reviewedEnd: null, publishedStart: null, publishedEnd: null }
|
||||
currentPage: 1, pageSize: 10,
|
||||
sortField: 'created_at', sortOrder: 'ascending',
|
||||
showSearch: false,
|
||||
searchForm: { id: '', title: '', field: '', createdStart: null, createdEnd: null, generatedStart: null, generatedEnd: null, reviewedStart: null, reviewedEnd: null, publishedStart: null, publishedEnd: null },
|
||||
createDialogVisible: false, createStep: 1, analyzing: false, saving: false,
|
||||
fieldOptions: [],
|
||||
createForm: { raw_input: '', reference_links: '', field_id: null },
|
||||
createResult: { title: '', format: '趋势洞察', core_concept: '', audience_pain: '', unique_angle: '', tags: [], priority: '中' }
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -558,6 +639,76 @@ const TopicsApp = {
|
||||
this.$message.error('删除失败: ' + msg);
|
||||
}
|
||||
},
|
||||
async openCreateDialog() {
|
||||
this.createDialogVisible = true;
|
||||
this.createStep = 1;
|
||||
this.createForm = { raw_input: '', reference_links: '', field_id: null };
|
||||
this.createResult = { title: '', format: '趋势洞察', core_concept: '', audience_pain: '', unique_angle: '', tags: [], priority: '中' };
|
||||
try {
|
||||
this.fieldOptions = await this.api('/api/topic-config/fields') || [];
|
||||
} catch (e) { this.fieldOptions = []; }
|
||||
},
|
||||
resetCreateDialog() {
|
||||
this.createStep = 1; this.analyzing = false; this.saving = false;
|
||||
},
|
||||
async runAiAnalysis() {
|
||||
if (!this.createForm.raw_input.trim()) { this.$message.warning('请输入选题思路'); return; }
|
||||
this.analyzing = true;
|
||||
try {
|
||||
const refs = this.createForm.reference_links.split('\n').map(s => s.trim()).filter(Boolean);
|
||||
const data = await this.api('/api/topics/analyze', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
raw_input: this.createForm.raw_input,
|
||||
reference_links: refs,
|
||||
field_id: this.createForm.field_id || null
|
||||
})
|
||||
});
|
||||
this.createResult.title = data.title || '';
|
||||
this.createResult.format = data.format || '趋势洞察';
|
||||
this.createResult.core_concept = data.core_concept || '';
|
||||
this.createResult.audience_pain = data.audience_pain || '';
|
||||
this.createResult.unique_angle = data.unique_angle || '';
|
||||
this.createResult.tags = data.tags || [];
|
||||
this.createResult.priority = data.priority || '中';
|
||||
this.createStep = 2;
|
||||
this.$message.success('AI 分析完成,请确认选题信息');
|
||||
} catch (e) { this.$message.error('AI 分析失败: ' + (e.message || '未知错误')); }
|
||||
finally { this.analyzing = false; }
|
||||
},
|
||||
async saveCreatedTopic(action) {
|
||||
this.saving = true;
|
||||
try {
|
||||
const body = {
|
||||
title: this.createResult.title,
|
||||
field_id: this.createForm.field_id || null,
|
||||
format: this.createResult.format,
|
||||
core_concept: this.createResult.core_concept,
|
||||
audience_pain: this.createResult.audience_pain,
|
||||
unique_angle: this.createResult.unique_angle,
|
||||
tags: this.createResult.tags,
|
||||
priority: this.createResult.priority,
|
||||
};
|
||||
const topic = await this.api('/api/topics', { method: 'POST', body: JSON.stringify(body) });
|
||||
const topicId = topic.id;
|
||||
this.createDialogVisible = false;
|
||||
this.$message.success(`选题 ${topicId} 创建成功`);
|
||||
await this.fetchTopics();
|
||||
if (action === 'create') {
|
||||
try {
|
||||
await this.api('/api/tasks/run-creator?topic_id=' + topicId, { method: 'POST' });
|
||||
this.$message.success(`创作任务已启动: ${topic.title}`);
|
||||
} catch (e) { this.$message.error(`创作启动失败: ${e.message}`); }
|
||||
} else if (action === 'review') {
|
||||
try {
|
||||
await this.api('/api/system/review/run', { method: 'POST', body: JSON.stringify({ topic_ids: [topicId] }) });
|
||||
this.$message.success(`审查已启动: ${topic.title}`);
|
||||
} catch (e) { this.$message.error(`审查启动失败: ${e.message}`); }
|
||||
}
|
||||
await this.fetchTodayCount();
|
||||
} catch (e) { this.$message.error('保存选题失败: ' + (e.message || '未知错误')); }
|
||||
finally { this.saving = false; }
|
||||
},
|
||||
switchPlatform(platform) {
|
||||
if (this.editing) this.cancelEdit();
|
||||
this.previewPlatform = platform;
|
||||
|
||||
Reference in New Issue
Block a user