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:
@@ -1,10 +1,11 @@
|
||||
{
|
||||
"sessionID": "ses_1dede8a7affeG6DxtfPUOviXrp",
|
||||
"updatedAt": "2026-06-17T07:11:26.600Z",
|
||||
"updatedAt": "2026-06-17T08:18:52.213Z",
|
||||
"sources": {
|
||||
"background-task": {
|
||||
"state": "idle",
|
||||
"updatedAt": "2026-06-17T07:11:26.600Z"
|
||||
"state": "active",
|
||||
"reason": "2 background task(s) active",
|
||||
"updatedAt": "2026-06-17T08:18:52.213Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ from ..database import get_db
|
||||
from ..models import User, Topic, SystemConfig
|
||||
from ..schemas import UserCreate, UserUpdate, UserResponse
|
||||
from ..core.audit_logger import audit_log
|
||||
from .auth import get_current_admin, org_filter
|
||||
from .auth import get_current_admin, require_role, org_filter
|
||||
import json
|
||||
|
||||
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import Optional
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import CollectorCategory, CollectorSource
|
||||
from .auth import get_current_admin
|
||||
from .auth import get_current_admin, require_role
|
||||
|
||||
router = APIRouter(prefix="/api/admin/collector", tags=["collector_mgmt"], dependencies=[Depends(get_current_admin)])
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import json
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import KeywordDomainMap, SensitiveWord, ContentCleanRule, TrendFieldMapping, CollectorCategory
|
||||
from .auth import get_current_admin
|
||||
from .auth import get_current_admin, require_role
|
||||
|
||||
router = APIRouter(prefix="/api/admin/config", tags=["admin"])
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from typing import List
|
||||
from ..database import get_db
|
||||
from ..models import LLMConfig
|
||||
from ..schemas import LLMConfigBase, LLMConfigResponse
|
||||
from .auth import get_current_admin
|
||||
from .auth import get_current_admin, require_role
|
||||
|
||||
router = APIRouter(prefix="/api/admin/llmconfigs", tags=["admin"])
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from typing import List
|
||||
from ..database import get_db
|
||||
from ..models import Menu, User
|
||||
from ..schemas import MenuCreate, MenuUpdate, MenuResponse
|
||||
from .auth import get_current_admin, get_current_user
|
||||
from .auth import get_current_admin, require_role, get_current_user
|
||||
|
||||
router = APIRouter(prefix="/api/admin/menus", tags=["admin"])
|
||||
public_router = APIRouter(prefix="/api/menus", tags=["menus"])
|
||||
|
||||
@@ -6,7 +6,7 @@ from datetime import datetime
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import PromptConfig
|
||||
from .auth import get_current_admin
|
||||
from .auth import get_current_admin, require_role
|
||||
|
||||
router = APIRouter(prefix="/api/admin/prompt-configs", tags=["admin"])
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from typing import List
|
||||
from ..database import get_db
|
||||
from ..models import Role, User
|
||||
from ..schemas import RoleCreate, RoleUpdate, RoleResponse
|
||||
from .auth import get_current_admin
|
||||
from .auth import get_current_admin, require_role
|
||||
|
||||
router = APIRouter(prefix="/api/admin/roles", tags=["admin"])
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from datetime import datetime, timezone
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import SearchProvider
|
||||
from .auth import get_current_admin
|
||||
from .auth import get_current_admin, require_role
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/search-providers", tags=["search_providers"])
|
||||
|
||||
@@ -5,7 +5,7 @@ from typing import List, Dict, Any
|
||||
from ..database import get_db
|
||||
from ..models import SystemConfig
|
||||
from ..schemas import SystemConfigBase, SystemConfigResponse
|
||||
from .auth import get_current_admin
|
||||
from .auth import get_current_admin, require_role
|
||||
|
||||
router = APIRouter(prefix="/api/admin/systemconfigs", tags=["admin"])
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ from typing import List, Optional
|
||||
from ..database import get_db
|
||||
from ..models import TaskConfig, TaskLog
|
||||
from ..schemas import TaskConfigBase, TaskConfigUpdate, TaskConfigResponse, TaskLogResponse
|
||||
from .auth import get_current_admin
|
||||
from .auth import get_current_admin, require_role
|
||||
|
||||
router = APIRouter(prefix="/api/admin/task-configs", tags=["admin"])
|
||||
|
||||
|
||||
@@ -174,7 +174,9 @@
|
||||
'topics.html': 'topics', 'metrics.html': 'metrics',
|
||||
'calendar.html': 'calendar', 'assets.html': 'assets',
|
||||
'tasks.html': 'tasks', 'platforms.html': 'platforms',
|
||||
'admin.html': 'admin'
|
||||
'admin.html': 'admin',
|
||||
'factory.html': 'factory',
|
||||
'insights.html': 'insights'
|
||||
};
|
||||
var path = window.location.pathname;
|
||||
var currentPage = pageMap[path] || pageMap['/'];
|
||||
@@ -188,6 +190,8 @@
|
||||
'tasks': '⚡ 任务页面跟踪所有创作任务。我可以解释各任务的含义、查看执行进度,或排查任务失败原因。',
|
||||
'platforms': '🔗 平台配置管理各发布渠道。我可以帮你了解如何配置平台参数、设置发布格式。',
|
||||
'admin': '⚙️ 系统管理页面。我可以帮你管理用户、配置 LLM、查看运行日志,或设置采集类别和信息源。',
|
||||
'factory': '🏭 这里是内容工厂。我可以帮你了解创作流程,从选题到发布的全链路跟踪,或排查任务失败原因。',
|
||||
'insights': '📈 数据洞察页面。我可以帮你解读内容表现数据、搜索排名变化趋势,或建议如何提升 GEO 就绪度。',
|
||||
};
|
||||
|
||||
var HISTORY_KEY = 'ai_assistant_history_' + currentPage;
|
||||
@@ -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) {
|
||||
|
||||
+401
-56
@@ -30,66 +30,274 @@
|
||||
.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>
|
||||
|
||||
<!-- 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 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 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="stat-card warning" @click="goToTopics('pending')">
|
||||
<div class="stat-title">待处理</div>
|
||||
<div class="stat-value">{{ stats.pending }}</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="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 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>
|
||||
|
||||
<!-- 模块状态 -->
|
||||
<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> 模块状态
|
||||
<!-- 今日待办 -->
|
||||
<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>
|
||||
<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 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 class="section-header">
|
||||
<div class="section-title">
|
||||
<el-icon><IconCalendar /></el-icon> 近期计划
|
||||
</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 ? '运行中' : '未启动' }}
|
||||
@@ -125,26 +333,9 @@
|
||||
</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>
|
||||
</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');
|
||||
|
||||
Reference in New Issue
Block a user