feat: Wave 3 - Dashboard redesign + RBAC + AI assistant updates

- Redesign index.html from system-monitor to user-centric workspace
  (welcome banner, summary cards, 今日待办, AI 智能建议, collapsible admin section)
- Add fetchTodos/fetchSuggestions/getPriorityClass/isOverdue methods
- Update uni-nav current-page from 'dashboard' to 'workspace'
- Add require_role to 10 admin router imports (RBAC foundation)
- Update ai-assistant.js: add factory/insights pages, org_id in chat requests
This commit is contained in:
Yuzhiran Dev
2026-06-17 18:24:57 +08:00
parent 5caf7bc52e
commit f6e5e90736
13 changed files with 466 additions and 114 deletions
+16 -10
View File
@@ -169,13 +169,15 @@
var token = localStorage.getItem('authToken');
if (!token) return;
var pageMap = {
'/': 'dashboard', '/index.html': 'dashboard',
'topics.html': 'topics', 'metrics.html': 'metrics',
'calendar.html': 'calendar', 'assets.html': 'assets',
'tasks.html': 'tasks', 'platforms.html': 'platforms',
'admin.html': 'admin'
};
var pageMap = {
'/': 'dashboard', '/index.html': 'dashboard',
'topics.html': 'topics', 'metrics.html': 'metrics',
'calendar.html': 'calendar', 'assets.html': 'assets',
'tasks.html': 'tasks', 'platforms.html': 'platforms',
'admin.html': 'admin',
'factory.html': 'factory',
'insights.html': 'insights'
};
var path = window.location.pathname;
var currentPage = pageMap[path] || pageMap['/'];
@@ -187,8 +189,10 @@
'assets': '🖼️ 素材库管理图片和文件。我可以指导你如何上传素材、添加标签分类,或关联到相关选题。',
'tasks': '⚡ 任务页面跟踪所有创作任务。我可以解释各任务的含义、查看执行进度,或排查任务失败原因。',
'platforms': '🔗 平台配置管理各发布渠道。我可以帮你了解如何配置平台参数、设置发布格式。',
'admin': '⚙️ 系统管理页面。我可以帮你管理用户、配置 LLM、查看运行日志,或设置采集类别和信息源。',
};
'admin': '⚙️ 系统管理页面。我可以帮你管理用户、配置 LLM、查看运行日志,或设置采集类别和信息源。',
'factory': '🏭 这里是内容工厂。我可以帮你了解创作流程,从选题到发布的全链路跟踪,或排查任务失败原因。',
'insights': '📈 数据洞察页面。我可以帮你解读内容表现数据、搜索排名变化趋势,或建议如何提升 GEO 就绪度。',
};
var HISTORY_KEY = 'ai_assistant_history_' + currentPage;
var saved = [];
@@ -289,7 +293,9 @@
var resp = await fetch('/api/assistant/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
body: JSON.stringify({ message: text, page: currentPage, history: history })
var currentUser = null;
try { currentUser = JSON.parse(localStorage.getItem('currentUser')); } catch(e) {}
body: JSON.stringify({ message: text, page: currentPage, history: history, org_id: currentUser?.org_id || 'default' })
});
removeTyping();
if (resp.ok) {
+436 -91
View File
@@ -30,121 +30,312 @@
.plan-item:hover { border-color: var(--color-primary); }
.plan-date { font-size: var(--font-size-caption); color: var(--color-text-secondary); font-weight: 600; min-width: 36px; }
.plan-title { flex: 1; font-size: var(--font-size-body); color: var(--color-text-primary); }
@media (max-width: 768px) { .module-grid { grid-template-columns: 1fr; } }
/* ===== Workspace summary cards ===== */
.workspace-summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: var(--spacing-md); margin-bottom: var(--spacing-xl); }
.ws-summary-card {
border-radius: var(--radius-lg); padding: var(--spacing-lg); cursor: pointer;
transition: all 0.3s var(--ease-out); position: relative; overflow: hidden;
border: 1px solid transparent;
}
.ws-summary-card:hover { transform: translateY(-4px); box-shadow: var(--shadow-lg); }
.ws-summary-card .ws-icon { font-size: 28px; margin-bottom: var(--spacing-sm); display: block; }
.ws-summary-card .ws-value { font-size: var(--font-size-h1); font-weight: 800; line-height: 1.2; }
.ws-summary-card .ws-label { font-size: var(--font-size-small); margin-top: var(--spacing-xs); opacity: 0.85; }
.ws-summary-card .ws-action { font-size: var(--font-size-caption); margin-top: var(--spacing-sm); opacity: 0.7; display: flex; align-items: center; gap: 4px; }
.ws-summary-card.ws-pending { background: linear-gradient(135deg, #fff7ed, #ffedd5); border-color: #fed7aa; }
.ws-summary-card.ws-pending .ws-value { color: #c2410c; }
.ws-summary-card.ws-review { background: linear-gradient(135deg, #fef2f2, #fee2e2); border-color: #fecaca; }
.ws-summary-card.ws-review .ws-value { color: #dc2626; }
.ws-summary-card.ws-ready { background: linear-gradient(135deg, #ecfdf5, #d1fae5); border-color: #a7f3d0; }
.ws-summary-card.ws-ready .ws-value { color: #059669; }
.ws-summary-card.ws-goal { background: linear-gradient(135deg, #eff6ff, #dbeafe); border-color: #bfdbfe; }
.ws-summary-card.ws-goal .ws-value { color: #2563eb; }
/* ===== Today Todo ===== */
.todo-list { display: flex; flex-direction: column; gap: var(--spacing-sm); }
.todo-item {
display: flex; align-items: flex-start; gap: var(--spacing-md);
padding: var(--spacing-md) var(--spacing-lg);
background: var(--color-bg-raised); border: 1px solid var(--color-border);
border-radius: var(--radius-md); transition: all 0.2s; cursor: pointer;
}
.todo-item:hover { border-color: var(--color-primary); box-shadow: var(--shadow-sm); }
.todo-priority { width: 8px; height: 8px; border-radius: 50%; margin-top: 6px; flex-shrink: 0; }
.todo-priority.high { background: var(--color-danger); box-shadow: 0 0 6px rgba(245,108,108,0.5); }
.todo-priority.medium { background: var(--color-warning); }
.todo-priority.low { background: var(--color-success); }
.todo-overdue { background: #fef2f2; border-color: #fecaca; }
.todo-overdue .todo-priority.high { animation: pulse-glow 2s infinite; }
.todo-content { flex: 1; min-width: 0; }
.todo-title { font-size: var(--font-size-body); font-weight: 600; color: var(--color-text-primary); margin-bottom: 2px; }
.todo-meta { font-size: var(--font-size-small); color: var(--color-text-secondary); }
.todo-tag { font-size: var(--font-size-caption); padding: 2px 8px; border-radius: var(--radius-sm); flex-shrink: 0; }
.todo-tag.overdue { background: #fef2f2; color: #dc2626; }
.todo-tag.priority { background: #fff7ed; color: #c2410c; }
/* ===== AI Suggestions ===== */
.ai-suggestion-card {
background: linear-gradient(135deg, var(--color-bg-raised), var(--color-bg-subtle));
border: 1px solid var(--color-border); border-radius: var(--radius-lg);
padding: var(--spacing-lg); margin-bottom: var(--spacing-sm);
transition: all 0.3s var(--ease-out);
}
.ai-suggestion-card:hover { border-color: var(--color-primary-light); box-shadow: var(--shadow-md); }
.ai-suggestion-header { display: flex; align-items: center; gap: var(--spacing-sm); margin-bottom: var(--spacing-sm); }
.ai-suggestion-icon { font-size: 20px; }
.ai-suggestion-title { font-size: var(--font-size-body); font-weight: 600; color: var(--color-text-primary); }
.ai-suggestion-body { font-size: var(--font-size-small); color: var(--color-text-regular); line-height: 1.6; margin-bottom: var(--spacing-sm); }
.ai-suggestion-actions { display: flex; gap: var(--spacing-sm); flex-wrap: wrap; }
/* ===== Admin Section ===== */
.admin-section-toggle {
display: flex; align-items: center; gap: var(--spacing-sm);
cursor: pointer; user-select: none; color: var(--color-text-secondary);
font-size: var(--font-size-small); margin-bottom: var(--spacing-md);
transition: color 0.2s;
}
.admin-section-toggle:hover { color: var(--color-text-regular); }
.admin-section-toggle .toggle-icon { transition: transform 0.3s var(--ease-out); }
.admin-section-toggle.collapsed .toggle-icon { transform: rotate(-90deg); }
/* ===== Welcome Banner ===== */
.welcome-banner {
background: linear-gradient(135deg, var(--color-primary), var(--color-accent));
border-radius: var(--radius-lg); padding: var(--spacing-xl) var(--spacing-xl);
color: white; margin-bottom: var(--spacing-xl); position: relative; overflow: hidden;
}
.welcome-banner::before {
content: ''; position: absolute; top: -50%; right: -20%;
width: 300px; height: 300px; border-radius: 50%;
background: rgba(255,255,255,0.08);
}
.welcome-banner::after {
content: ''; position: absolute; bottom: -60%; left: -10%;
width: 250px; height: 250px; border-radius: 50%;
background: rgba(255,255,255,0.05);
}
.welcome-greeting { font-size: var(--font-size-h2); font-weight: 700; margin-bottom: var(--spacing-xs); position: relative; z-index: 1; }
.welcome-sub { font-size: var(--font-size-body); opacity: 0.9; position: relative; z-index: 1; }
/* ===== Section Headers ===== */
.section-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: var(--spacing-md); margin-top: var(--spacing-xl);
}
.section-header:first-of-type { margin-top: 0; }
.section-title {
font-size: var(--font-size-h3); font-weight: 600;
color: var(--color-text-primary); display: flex; align-items: center; gap: var(--spacing-sm);
}
@media (max-width: 768px) {
.module-grid { grid-template-columns: 1fr; }
.workspace-summary { grid-template-columns: repeat(2, 1fr); }
.ws-summary-card { padding: var(--spacing-md); }
.ws-summary-card .ws-value { font-size: 24px; }
.welcome-banner { padding: var(--spacing-lg); }
.welcome-greeting { font-size: var(--font-size-h3); }
}
</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="dashboard" @navigate="redirectToPage" @logout="handleLogout">
<uni-nav title="宇之然" :username="currentUser.username" :is-admin="isAdmin" current-page="workspace" @navigate="redirectToPage" @logout="handleLogout">
</uni-nav>
<div class="main-content" v-if="isLoggedIn">
<main class="content-area">
<!-- 系统概览页面 -->
<!-- 工作台页面 -->
<div id="page-overview" class="page" :class="{ active: currentPage === 'overview' }">
<h2 style="font-size: var(--font-size-h1); font-weight: 700; margin-bottom: var(--spacing-xl); color: var(--color-text-primary);">
<el-icon style="vertical-align:-2px;"><IconDashboard /></el-icon> 系统概览
</h2>
<!-- Welcome Banner -->
<div class="welcome-banner">
<div class="welcome-greeting">☀️ 早上好,{{ currentUser.username || '创作者' }}</div>
<div class="welcome-sub">
你的团队本月已完成 {{ stats.published }} 篇内容发布
<template v-if="stats.today"> · 今天新增 {{ stats.today }} 个选题</template>
</div>
</div>
<!-- 加载状态 -->
<!-- Loading State -->
<div v-if="loadingStats" style="text-align:center;padding:40px 0;color:#909399;">
<el-icon style="font-size:32px;margin-bottom:12px;" class="is-loading"><IconLoading /></el-icon>
<div>加载中...</div>
</div>
<!-- 空状态 -->
<div v-else-if="!stats.total && !loadingStats" class="empty-state">
<el-icon style="font-size:48px;color:#c0c4cc;"><IconDashboard /></el-icon>
<div class="empty-text">暂无数据</div>
</div>
<!-- 统计卡片 -->
<div v-else class="stats-grid">
<div class="stat-card primary" @click="goToTopics('')">
<div class="stat-title">选题总数</div>
<div class="stat-value">{{ stats.total }}</div>
</div>
<div class="stat-card warning" @click="goToTopics('pending')">
<div class="stat-title">待处理</div>
<div class="stat-value">{{ stats.pending }}</div>
</div>
<div class="stat-card danger" @click="goToTopics('review')">
<div class="stat-title">待审查</div>
<div class="stat-value">{{ stats.review }}</div>
</div>
<div class="stat-card success" @click="goToTopics('ready')">
<div class="stat-title">待发布</div>
<div class="stat-value">{{ stats.ready }}</div>
</div>
<div class="stat-card info" @click="goToTopics('published')">
<div class="stat-title">已发布</div>
<div class="stat-value">{{ stats.published }}</div>
</div>
<div class="stat-card primary" @click="goToTopics('today')">
<div class="stat-title">今日新增</div>
<div class="stat-value">{{ stats.today }}</div>
</div>
</div>
<!-- 模块状态 -->
<h3 style="font-size: var(--font-size-h2); font-weight: 600; margin-bottom: var(--spacing-lg); color: var(--color-text-primary);">
<el-icon style="vertical-align:-2px;"><IconSetting /></el-icon> 模块状态
<span style="font-size: var(--font-size-small); font-weight: 400; color: var(--color-text-secondary); margin-left: var(--spacing-sm);">
定时任务:
<el-icon v-if="schedulerRunning" style="color:#67C23A;vertical-align:-2px;"><IconCheck /></el-icon>
<el-icon v-else style="color:#F56C6C;vertical-align:-2px;"><IconClose /></el-icon>
{{ schedulerRunning ? '运行中' : '未启动' }}
</span>
</h3>
<div class="module-grid">
<div v-for="mod in modules" :key="mod.module_id" class="module-card">
<div class="module-header">
<span class="module-title">{{ mod.title }}</span>
<span :class="['module-status', mod.enabled ? '' : 'inactive']">
<el-icon v-if="mod.enabled" style="vertical-align:-2px;"><IconCheck /></el-icon>
<el-icon v-else style="vertical-align:-2px;"><IconClose /></el-icon>
{{ mod.enabled ? '已启用' : '已禁用' }}
</span>
<!-- Workspace Content -->
<template v-else>
<!-- Summary Cards Row -->
<div class="workspace-summary">
<div class="ws-summary-card ws-pending" @click="goToTopics('pending')">
<span class="ws-icon">📋</span>
<div class="ws-value">{{ stats.pending }}</div>
<div class="ws-label">待处理选题</div>
<div class="ws-action">点击查看 →</div>
</div>
<div class="module-content">
<div><span>执行时间</span><span>{{ mod.schedule || mod.cron_default }}</span></div>
<div><span>成功/失败</span><span style="color:#67c23a;">{{ mod.success_runs }}</span> / <span style="color:#f56c6c;">{{ mod.failed_runs }}</span></div>
<div><span>今日任务</span><span>{{ mod.total_runs }} 次</span></div>
<div><span>下次运行</span><span style="color:#409eff;">{{ mod.next_run || '—' }}</span></div>
<div v-if="mod.last_result && mod.last_result.topics_count" style="border-bottom:none;">
<span>最新产出</span>
<span style="color:#409eff;font-weight:600;">{{ mod.last_result.topics_count }} 个选题</span>
<div class="ws-summary-card ws-review" @click="redirectToPage('/factory.html')">
<span class="ws-icon">🔍</span>
<div class="ws-value">{{ stats.review }}</div>
<div class="ws-label">待审查文章</div>
<div class="ws-action">内容工厂 →</div>
</div>
<div class="ws-summary-card ws-ready" @click="goToTopics('ready')">
<span class="ws-icon">📦</span>
<div class="ws-value">{{ stats.ready }}</div>
<div class="ws-label">待发布内容</div>
<div class="ws-action">去发布 →</div>
</div>
<div class="ws-summary-card ws-goal">
<span class="ws-icon">🎯</span>
<div class="ws-value">{{ monthlyGoal }}</div>
<div class="ws-label">本月目标</div>
<div class="ws-action">{{ monthlyProgress }}% 完成</div>
</div>
</div>
<!-- 今日待办 -->
<div class="section-header">
<div class="section-title">
<el-icon><IconList /></el-icon> 今日待办
</div>
<el-button size="small" text type="primary" @click="goToTopics('')">查看全部选题</el-button>
</div>
<div v-if="loadingTodos" style="text-align:center;padding:20px;color:#909399;">
<el-icon style="font-size:24px;" class="is-loading"><IconLoading /></el-icon>
</div>
<div v-else-if="todoTopics.length === 0" class="empty-state" style="padding:20px;">
<el-icon style="font-size:36px;color:#c0c4cc;"><IconCheck /></el-icon>
<div class="empty-text">暂无待办事项,太棒了!</div>
</div>
<div v-else class="todo-list">
<div v-for="topic in todoTopics" :key="topic.id"
:class="['todo-item', { 'todo-overdue': isOverdue(topic) }]"
@click="goToTopics('')">
<span class="todo-priority" :class="getPriorityClass(topic)"></span>
<div class="todo-content">
<div class="todo-title">{{ topic.title }}</div>
<div class="todo-meta">{{ getStatusLabel(topic.status) }}
<span v-if="topic.platform_names"> · {{ topic.platform_names }}</span>
</div>
</div>
<div v-else-if="mod.last_result && mod.last_result.articles_synced" style="border-bottom:none;">
<span>最新产出</span>
<span style="color:#409eff;font-weight:600;">{{ mod.last_result.articles_synced }} 篇</span>
<el-tag v-if="isOverdue(topic)" type="danger" size="small" class="todo-tag overdue">逾期</el-tag>
<el-tag v-else :type="getPriorityTagType(topic)" size="small" class="todo-tag priority">{{ getPriorityLabel(topic) }}</el-tag>
</div>
</div>
<!-- AI 智能建议 -->
<div class="section-header">
<div class="section-title">
<el-icon><IconLightning /></el-icon> AI 智能建议
</div>
</div>
<div v-if="loadingSuggestions" style="text-align:center;padding:20px;color:#909399;">
<el-icon style="font-size:24px;" class="is-loading"><IconLoading /></el-icon>
</div>
<div v-else>
<div class="ai-suggestion-card">
<div class="ai-suggestion-header">
<span class="ai-suggestion-icon">💡</span>
<span class="ai-suggestion-title">快速创建选题</span>
</div>
<div style="margin-top:10px; border-bottom:none;">
<el-button v-if="isAdmin" size="small" type="primary" @click="openModuleDetail(mod)">详情</el-button>
<el-button size="small" @click="triggerModule(mod.module_id || mod.id)" :loading="runningModule === (mod.module_id || mod.id)" :disabled="!mod.enabled">立即运行</el-button>
<div class="ai-suggestion-body">
根据近期热点趋势,建议创建新的选题来保持内容输出节奏。
</div>
<div class="ai-suggestion-actions">
<el-button size="small" type="primary" @click="redirectToPage('/topics.html?action=create')">
<el-icon style="margin-right:4px;"><IconPlus /></el-icon> 一键创建选题
</el-button>
<el-button size="small" @click="goToTopics('')">浏览现有选题</el-button>
</div>
</div>
<div class="ai-suggestion-card" v-if="topTrendingTopic">
<div class="ai-suggestion-header">
<span class="ai-suggestion-icon">📈</span>
<span class="ai-suggestion-title">热门趋势推荐</span>
</div>
<div class="ai-suggestion-body">
「{{ topTrendingTopic.title }}」是当前高热度选题方向
<span v-if="topTrendingTopic.score">(评分 {{ topTrendingTopic.score }}</span>
建议优先安排创作。
</div>
<div class="ai-suggestion-actions">
<el-button size="small" @click="goToTopics('')">查看详情 →</el-button>
</div>
</div>
</div>
</div>
<!-- 近期计划 -->
<h3 style="font-size: var(--font-size-h2); font-weight: 600; margin-bottom: var(--spacing-lg); color: var(--color-text-primary);">
<el-icon style="vertical-align:-2px;"><IconCalendar /></el-icon> 近期计划
</h3>
<div v-if="loadingPlan" style="text-align:center;padding:20px 0;color:#909399;">
<el-icon style="font-size:24px;" class="is-loading"><IconLoading /></el-icon>
<div style="margin-top:8px;">加载中...</div>
</div>
<div v-else-if="upcomingEntries.length === 0" class="empty-state" style="padding:20px;">
<el-icon style="font-size:36px;color:#c0c4cc;"><IconCalendar /></el-icon>
<div class="empty-text">近期暂无计划</div>
</div>
<div v-else class="plan-list">
<div v-for="entry in upcomingEntries" :key="entry.id || entry.date" class="plan-item">
<span class="plan-date">{{ entry.date.slice(5) }}</span>
<span class="plan-title">{{ entry.title }}</span>
<el-tag v-if="entry.status" :type="entry.status === 'published' ? 'success' : 'warning'" size="small">{{ entry.status === 'published' ? '已发布' : '计划' }}</el-tag>
<!-- 近期计划 -->
<div class="section-header">
<div class="section-title">
<el-icon><IconCalendar /></el-icon> 近期计划
</div>
</div>
</div>
<div v-if="loadingPlan" style="text-align:center;padding:20px 0;color:#909399;">
<el-icon style="font-size:24px;" class="is-loading"><IconLoading /></el-icon>
<div style="margin-top:8px;">加载中...</div>
</div>
<div v-else-if="upcomingEntries.length === 0" class="empty-state" style="padding:20px;">
<el-icon style="font-size:36px;color:#c0c4cc;"><IconCalendar /></el-icon>
<div class="empty-text">近期暂无计划</div>
</div>
<div v-else class="plan-list">
<div v-for="entry in upcomingEntries" :key="entry.id || entry.date" class="plan-item">
<span class="plan-date">{{ entry.date.slice(5) }}</span>
<span class="plan-title">{{ entry.title }}</span>
<el-tag v-if="entry.status" :type="entry.status === 'published' ? 'success' : 'warning'" size="small">{{ entry.status === 'published' ? '已发布' : '计划' }}</el-tag>
</div>
</div>
<!-- 管理员模块状态 (可折叠) -->
<div v-if="isAdmin" style="margin-top: var(--spacing-xl);">
<div :class="['admin-section-toggle', { collapsed: adminCollapsed }]" @click="adminCollapsed = !adminCollapsed">
<el-icon class="toggle-icon"><IconArrowRight /></el-icon>
<span>系统模块状态(管理员)</span>
</div>
<div v-show="!adminCollapsed">
<h3 style="font-size: var(--font-size-h3); font-weight: 600; margin-bottom: var(--spacing-md); color: var(--color-text-primary);">
<el-icon style="vertical-align:-2px;"><IconSetting /></el-icon> 定时任务模块
<span style="font-size: var(--font-size-small); font-weight: 400; color: var(--color-text-secondary); margin-left: var(--spacing-sm);">
调度器:
<el-icon v-if="schedulerRunning" style="color:#67C23A;vertical-align:-2px;"><IconCheck /></el-icon>
<el-icon v-else style="color:#F56C6C;vertical-align:-2px;"><IconClose /></el-icon>
{{ schedulerRunning ? '运行中' : '未启动' }}
</span>
</h3>
<div class="module-grid">
<div v-for="mod in modules" :key="mod.module_id" class="module-card">
<div class="module-header">
<span class="module-title">{{ mod.title }}</span>
<span :class="['module-status', mod.enabled ? '' : 'inactive']">
<el-icon v-if="mod.enabled" style="vertical-align:-2px;"><IconCheck /></el-icon>
<el-icon v-else style="vertical-align:-2px;"><IconClose /></el-icon>
{{ mod.enabled ? '已启用' : '已禁用' }}
</span>
</div>
<div class="module-content">
<div><span>执行时间</span><span>{{ mod.schedule || mod.cron_default }}</span></div>
<div><span>成功/失败</span><span style="color:#67c23a;">{{ mod.success_runs }}</span> / <span style="color:#f56c6c;">{{ mod.failed_runs }}</span></div>
<div><span>今日任务</span><span>{{ mod.total_runs }} 次</span></div>
<div><span>下次运行</span><span style="color:#409eff;">{{ mod.next_run || '—' }}</span></div>
<div v-if="mod.last_result && mod.last_result.topics_count" style="border-bottom:none;">
<span>最新产出</span>
<span style="color:#409eff;font-weight:600;">{{ mod.last_result.topics_count }} 个选题</span>
</div>
<div v-else-if="mod.last_result && mod.last_result.articles_synced" style="border-bottom:none;">
<span>最新产出</span>
<span style="color:#409eff;font-weight:600;">{{ mod.last_result.articles_synced }} 篇</span>
</div>
<div style="margin-top:10px; border-bottom:none;">
<el-button v-if="isAdmin" size="small" type="primary" @click="openModuleDetail(mod)">详情</el-button>
<el-button size="small" @click="triggerModule(mod.module_id || mod.id)" :loading="runningModule === (mod.module_id || mod.id)" :disabled="!mod.enabled">立即运行</el-button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
</div>
<!-- 模块详情抽屉 -->
@@ -214,6 +405,8 @@
currentUser: { username: '' },
currentPage: 'overview',
loadingStats: false,
loadingTodos: false,
loadingSuggestions: false,
stats: {
total: 0,
pending: 0,
@@ -222,6 +415,11 @@
published: 0,
today: 0
},
todoTopics: [],
monthlyGoal: 0,
monthlyProgress: 0,
topTrendingTopic: null,
adminCollapsed: true,
modules: [],
schedulerStatus: '',
schedulerRunning: false,
@@ -254,6 +452,10 @@
this.isLoggedIn = true;
this.currentPage = 'overview';
this.fetchStats();
this.fetchModules();
this.fetchUpcomingPlan();
this.fetchTopTopics();
this.computeMonthlyGoal();
} catch (error) {
this.loginError = '用户名或密码错误';
} finally {
@@ -443,6 +645,145 @@
this.$message.success('已保存');
} catch (e) { this.$message.error('保存失败: ' + e.message); }
finally { this.promptSaving = null; }
},
async fetchTodos() {
this.loadingTodos = true;
try {
const token = localStorage.getItem('authToken');
const resp = await fetch('/api/topics?limit=10&sort_by=priority_score&order=desc', {
headers: { 'Authorization': 'Bearer ' + token }
});
if (resp.ok) {
const data = await resp.json();
this.todoTopics = (data || []).filter(t => t.status === 'pending' || t.status === 'review' || t.status === 'ready');
}
} catch (e) {
console.error('获取待办失败:', e);
} finally { this.loadingTodos = false; }
},
async fetchSuggestions() {
this.loadingSuggestions = true;
try {
const token = localStorage.getItem('authToken');
const resp = await fetch('/api/topics?limit=5&sort_by=priority_score&order=desc', {
headers: { 'Authorization': 'Bearer ' + token }
});
if (resp.ok) {
const data = await resp.json();
if (data && data.length > 0) {
this.topTrendingTopic = {
title: data[0].title,
score: data[0].total_score || data[0].priority_score
};
}
}
} catch (e) {
console.error('获取建议失败:', e);
} finally { this.loadingSuggestions = false; }
},
getPriorityClass(topic) {
const p = topic.priority || '';
if (p === '高' || p === 'high' || topic.priority_score >= 80) return 'high';
if (p === '中' || p === 'medium' || topic.priority_score >= 40) return 'medium';
return 'low';
},
getPriorityTagType(topic) {
const cls = this.getPriorityClass(topic);
return cls === 'high' ? 'danger' : cls === 'medium' ? 'warning' : 'info';
},
getPriorityLabel(topic) {
const p = topic.priority || '';
if (p === '高' || p === 'high' || topic.priority_score >= 80) return '高优先级';
if (p === '中' || p === 'medium' || topic.priority_score >= 40) return '中优先级';
return '普通';
},
isOverdue(topic) {
if (!topic.ready_at && !topic.published_at) return false;
const target = new Date(topic.ready_at || topic.published_at);
const today = new Date();
today.setHours(0, 0, 0, 0);
return target < today && topic.status !== 'published';
},
getStatusLabel(status) {
const map = { 'pending': '待处理', 'review': '待审查', 'ready': '待发布', 'published': '已发布', 'draft': '草稿' };
return map[status] || status;
},
getPriorityClass(topic) {
if (topic.priority_score >= 80) return 'high';
if (topic.priority_score >= 50) return 'medium';
return 'low';
},
getPriorityLabel(topic) {
if (topic.priority_score >= 80) return '高优先级';
if (topic.priority_score >= 50) return '中优先级';
return '低优先级';
},
getPriorityTagType(topic) {
if (topic.priority_score >= 80) return 'danger';
if (topic.priority_score >= 50) return 'warning';
return 'success';
},
getStatusLabel(status) {
const map = {
'pending': '待处理', 'review': '待审查', 'draft': '草稿',
'ready': '待发布', 'published': '已发布'
};
return map[status] || status;
},
isOverdue(topic) {
if (!topic.due_date) return false;
try {
const due = new Date(topic.due_date);
const now = new Date();
now.setHours(23, 59, 59, 999);
return due < now;
} catch (e) { return false; }
},
computeMonthlyGoal() {
try {
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth() + 1;
const published = this.stats.published || 0;
// Goal: 10 articles/month minimum, shown as percentage
const goal = Math.max(10, published * 1.5);
this.monthlyGoal = Math.round(goal);
this.monthlyProgress = Math.min(100, Math.round((published / goal) * 100));
} catch (e) {
this.monthlyGoal = 10;
this.monthlyProgress = 0;
}
},
async fetchTopTopics() {
this.loadingTodos = true;
this.loadingSuggestions = true;
try {
const token = localStorage.getItem('authToken');
if (!token) return;
// Top priority topics for todo list
const topicsResp = await fetch('/api/topics?limit=10&sort_by=priority_score&order=desc', {
headers: { 'Authorization': 'Bearer ' + token }
});
if (topicsResp.ok) {
const topics = await topicsResp.json();
// Filter to pending/review/ready for todo list
this.todoTopics = (topics || []).filter(t =>
['pending', 'review', 'draft', 'ready'].includes(t.status)
).slice(0, 5);
// Top trending for AI suggestion
if (this.todoTopics.length > 0) {
this.topTrendingTopic = this.todoTopics[0];
}
}
} catch (e) {
console.error('获取待办话题失败:', e);
} finally {
this.loadingTodos = false;
this.loadingSuggestions = false;
}
},
toggleAdminSection() {
this.adminCollapsed = !this.adminCollapsed;
}
},
mounted() {
@@ -463,6 +804,10 @@
this.fetchStats();
this.fetchModules();
this.fetchUpcomingPlan();
this.fetchTopTopics();
this.computeMonthlyGoal();
this.fetchTodos();
this.fetchSuggestions();
})
.catch(() => {
localStorage.removeItem('authToken');