Files
yu-zhi-ran/platform/frontend/index.html
T
Yuzhiran Dev f6e5e90736 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
2026-06-17 18:24:57 +08:00

828 lines
50 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>
.page { display: none; animation: fadeIn 0.5s ease-out; }
.page.active { display: block; }
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.module-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: var(--spacing-lg); }
.module-card { background: var(--color-bg-raised); border: 1px solid var(--color-border); border-radius: var(--radius-lg); padding: var(--spacing-lg); transition: all 0.3s var(--ease-out); }
.module-card:hover { transform: translateY(-6px); border-color: var(--color-border-hover); box-shadow: var(--shadow-lg); }
.module-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--spacing-md); }
.module-title { font-size: var(--font-size-h3); font-weight: 600; color: var(--color-text-primary); display: flex; align-items: center; gap: var(--spacing-sm); }
.module-status { padding: 6px 12px; border-radius: var(--radius-xl); font-size: var(--font-size-caption); font-weight: 600; background: rgba(103,194,58,0.12); color: #67c23a; border: 1px solid rgba(103,194,58,0.25); }
.module-status.inactive { background: rgba(144,147,153,0.12); color: #909399; border: 1px solid rgba(144,147,153,0.25); }
.module-drawer .el-drawer__body { padding: 20px; overflow-y: auto; }
.module-content { font-size: var(--font-size-body); color: var(--color-text-regular); line-height: 1.8; }
.module-content div { display: flex; justify-content: space-between; padding: 4px 0; border-bottom: 1px dashed var(--color-border); }
.module-content div:last-child { border-bottom: none; }
.stat-title { font-size: var(--font-size-small); color: var(--color-text-secondary); margin-bottom: var(--spacing-md); text-transform: uppercase; letter-spacing: 0.5px; font-weight: 500; }
.plan-list { display: flex; flex-direction: column; gap: 8px; }
.plan-item { display: flex; align-items: center; gap: 12px; padding: 10px 14px; background: var(--color-bg-raised); border: 1px solid var(--color-border); border-radius: var(--radius-md); transition: all 0.2s; }
.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); }
/* ===== 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="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' }">
<!-- 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>
<!-- 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="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>
<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 ? '运行中' : '未启动' }}
</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>
<!-- 模块详情抽屉 -->
<el-drawer v-model="showModuleDrawer" :title="(moduleDetail ? moduleDetail.title : '') + ' 详情'" size="50%" direction="rtl" class="module-drawer">
<div v-if="moduleDetailLoading" style="text-align:center;padding:40px;color:#909399;">加载中...</div>
<div v-else-if="moduleDetail" style="padding:0 20px;">
<div style="margin-bottom:16px;display:flex;gap:16px;align-items:center;">
<el-tag :type="moduleDetail.enabled ? 'success' : 'info'" size="small">{{ moduleDetail.enabled ? '已启用' : '已禁用' }}</el-tag>
<span style="font-size:13px;color:#909399;">执行时间: {{ moduleDetail.schedule || moduleDetail.cron_default }}</span>
</div>
<el-tabs style="height:calc(100vh - 200px);">
<el-tab-pane label="📋 运行记录" name="history" style="overflow:auto;">
<div v-if="moduleDetailHistory && moduleDetailHistory.length > 0">
<el-timeline>
<el-timeline-item v-for="(h, i) in moduleDetailHistory" :key="h.id || i"
:timestamp="h.started_at ? new Date(h.started_at).toLocaleString('zh-CN', {timeZone:'Asia/Shanghai'}) : '—'"
:color="h.status === 'success' ? '#67c23a' : h.status === 'running' ? '#e6a23c' : '#f56c6c'">
<div style="font-size:13px;">
<el-tag :type="h.status === 'success' ? 'success' : h.status === 'running' ? 'warning' : 'danger'" size="small" style="margin-right:6px;">{{ h.status }}</el-tag>
<span v-if="h.message">{{ h.message }}</span>
<span v-else style="color:#909399;"></span>
</div>
<div v-if="h.error_trace" style="margin-top:4px;padding:6px 8px;background:#fef0f0;border-radius:4px;font-size:12px;color:#f56c6c;max-height:60px;overflow:auto;">{{ h.error_trace }}</div>
<div style="font-size:12px;color:#c0c4cc;margin-top:2px;">耗时 {{ h.duration ? h.duration + 's' : '—' }} · {{ h.triggered_by }}</div>
</el-timeline-item>
</el-timeline>
</div>
<div v-else style="color:#909399;padding:20px;text-align:center;">暂无运行记录</div>
</el-tab-pane>
<el-tab-pane label="🤖 提示词" name="prompts" style="overflow:auto;">
<div v-if="moduleDetailPrompts && moduleDetailPrompts.length > 0">
<div v-for="p in moduleDetailPrompts" :key="p.id" style="margin-bottom:12px;border:1px solid #ebeef5;border-radius:10px;padding:12px;">
<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:6px;">
<div>
<el-tag size="small" type="info" style="margin-right:6px;">{{ p.category }}</el-tag>
<span style="font-weight:600;font-size:13px;">{{ p.key }}</span>
<div style="font-size:11px;color:#909399;margin-top:2px;">{{ p.description }}</div>
</div>
<el-switch v-model="p.enabled" @change="savePrompt(p)" size="small" :disabled="promptSaving === p.id"></el-switch>
</div>
<el-input type="textarea" v-model="p.content" :rows="5" style="font-family:monospace;font-size:12px;" @blur="savePrompt(p)"></el-input>
</div>
</div>
<div v-else style="color:#909399;padding:20px;text-align:center;">此模块暂无提示词配置</div>
</el-tab-pane>
</el-tabs>
<div style="padding-top:16px;border-top:1px solid #ebeef5;text-align:center;">
<el-button type="primary" @click="triggerModule(moduleDetail.module_id)" :loading="runningModule === moduleDetail.module_id" :disabled="!moduleDetail.enabled">立即运行此任务</el-button>
</div>
</div>
</el-drawer>
</main>
</div>
<!-- 移动端导航 -->
</div>
<script src="vue.global.prod.js"></script>
<script src="icon-components.js"></script>
<script src="element-plus.full.js"></script>
<script>
const App = {
data() {
return {
isLoggedIn: false,
isAdmin: false,
currentUser: { username: '' },
currentPage: 'overview',
loadingStats: false,
loadingTodos: false,
loadingSuggestions: false,
stats: {
total: 0,
pending: 0,
review: 0,
ready: 0,
published: 0,
today: 0
},
todoTopics: [],
monthlyGoal: 0,
monthlyProgress: 0,
topTrendingTopic: null,
adminCollapsed: true,
modules: [],
schedulerStatus: '',
schedulerRunning: false,
upcomingEntries: [],
loadingPlan: false,
runningModule: null,
showModuleDrawer: false,
moduleDetail: null,
moduleDetailLoading: false,
moduleDetailHistory: [],
moduleDetailPrompts: [],
promptSaving: null
};
},
methods: {
async handleLogin() {
this.loginLoading = true;
this.loginError = '';
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.loginForm)
});
if (!response.ok) throw new Error('登录失败');
const data = await response.json();
localStorage.setItem('authToken', data.token);
this.currentUser = data.user;
this.isAdmin = data.user.role === 'admin';
this.isLoggedIn = true;
this.currentPage = 'overview';
this.fetchStats();
this.fetchModules();
this.fetchUpcomingPlan();
this.fetchTopTopics();
this.computeMonthlyGoal();
} catch (error) {
this.loginError = '用户名或密码错误';
} finally {
this.loginLoading = false;
}
},
handleLogout() {
localStorage.removeItem('authToken');
localStorage.removeItem('userRole');
localStorage.removeItem('currentUser');
this.isLoggedIn = false;
this.currentUser = { username: '' };
this.isAdmin = false;
window.location.href = '/login.html';
},
async fetchStats() {
this.loadingStats = true;
try {
const token = localStorage.getItem('authToken');
if (!token) {
window.location.href = '/login.html';
return;
}
const response = await fetch('/api/system/status', {
headers: { 'Authorization': 'Bearer ' + token }
});
if (response.ok) {
const data = await response.json();
// API返回格式: { stats: { total, pending, review, ready, published, today } }
this.stats = {
total: data.stats?.total || 0,
pending: data.stats?.pending || 0,
review: data.stats?.review || 0,
ready: data.stats?.ready || 0,
published: data.stats?.published || 0,
today: data.stats?.today || 0
};
} else if (response.status === 401) {
// Token无效,清除并跳转登录
localStorage.removeItem('authToken');
window.location.href = '/login.html';
} else {
console.error('获取统计信息失败:', response.status, response.statusText);
this.stats = { total: 0, pending: 0, review: 0, ready: 0, published: 0, today: 0 };
}
} catch (error) {
console.error('获取统计信息失败:', error);
this.$message.error('获取统计信息失败: ' + error.message);
this.stats = { total: 0, pending: 0, review: 0, ready: 0, published: 0, today: 0 };
} finally { this.loadingStats = false; }
},
async fetchModules() {
try {
const token = localStorage.getItem('authToken');
const resp = await fetch('/api/system/modules/status', {
headers: { 'Authorization': 'Bearer ' + token }
});
if (resp.ok) {
const data = await resp.json();
this.modules = data.modules || [];
const s = data.scheduler || {};
this.schedulerRunning = s.running;
this.schedulerStatus = (s.running ? `运行中 · ${s.jobs ? s.jobs.length : 0} 个任务` : '未启动');
}
} catch (e) {
console.error('获取模块状态失败:', e);
this.$message.error('获取模块状态失败: ' + e.message);
}
},
async fetchUpcomingPlan() {
this.loadingPlan = true;
try {
const token = localStorage.getItem('authToken');
const today = new Date();
today.setHours(0, 0, 0, 0);
const end = new Date(today);
end.setDate(end.getDate() + 7);
const [entries, topics] = await Promise.all([
fetch(`/api/calendar?year=${today.getFullYear()}&month=${today.getMonth() + 1}`, {
headers: { 'Authorization': 'Bearer ' + token }
}).then(r => r.ok ? r.json() : []),
fetch('/api/topics?limit=200', {
headers: { 'Authorization': 'Bearer ' + token }
}).then(r => r.ok ? r.json() : [])
]);
const events = [];
(entries || []).forEach(e => {
events.push({
id: 'cal_' + e.id,
date: e.planned_date,
title: e.title,
status: e.status === 'published' ? 'published' : 'planned'
});
});
(topics || []).forEach(t => {
const s = t.status;
if ((s === 'ready' || s === '待发布') && t.ready_at) {
events.push({
id: 'topic_ready_' + t.id,
date: t.ready_at,
title: t.title,
status: 'planned'
});
} else if ((s === 'published' || s === '已发布') && t.published_at) {
events.push({
id: 'topic_pub_' + t.id,
date: t.published_at,
title: t.title,
status: 'published'
});
}
});
this.upcomingEntries = events
.filter(e => { const d = new Date(e.date); return d >= today && d < end; })
.sort((a, b) => a.date.localeCompare(b.date));
} catch (e) {
console.error('获取近期计划失败:', e);
} finally { this.loadingPlan = false; }
},
goToTopics(filter) {
const url = filter ? '/topics.html?filter=' + encodeURIComponent(filter) : '/topics.html';
window.location.href = url;
},
async triggerModule(modId) {
this.runningModule = modId;
const endpoints = {
scheduled_collect: '/api/system/collect/run',
scheduled_fetch_trends: '/api/system/trends/run',
scheduled_generate: '/api/system/generate/run',
scheduled_optimize: '/api/system/review/run',
scheduled_optimize_sources: '/api/system/optimize-sources/run',
scheduled_metrics_sync: '/api/system/metrics-sync/run',
};
const endpoint = endpoints[modId];
if (!endpoint) { this.$message.error('未知模块'); this.runningModule = null; return; }
try {
const token = localStorage.getItem('authToken');
const resp = await fetch(endpoint, { method: 'POST', headers: { 'Authorization': 'Bearer ' + token } });
if (resp.ok) {
const data = await resp.json();
this.$message.success(data.message || '执行成功');
this.fetchModules();
} else {
const err = await resp.json().catch(() => ({}));
this.$message.error(err.detail || '执行失败');
}
} catch (e) {
this.$message.error('执行失败: ' + e.message);
} finally { this.runningModule = null; }
},
redirectToPage(page) {
window.location.href = page.startsWith('/') ? page : '/' + page;
},
async openModuleDetail(mod) {
this.showModuleDrawer = true;
this.moduleDetail = mod;
this.moduleDetailLoading = true;
this.moduleDetailHistory = [];
this.moduleDetailPrompts = [];
const token = localStorage.getItem('authToken');
try {
const [history, prompts] = await Promise.all([
fetch('/api/admin/task-configs/history/' + mod.module_id + '?limit=20', {
headers: { 'Authorization': 'Bearer ' + token }
}).then(r => r.ok ? r.json() : []),
fetch('/api/admin/prompt-configs?module_id=' + mod.module_id, {
headers: { 'Authorization': 'Bearer ' + token }
}).then(r => r.ok ? r.json() : [])
]);
this.moduleDetailHistory = history || [];
this.moduleDetailPrompts = prompts || [];
} catch (e) { this.$message.error('加载详情失败: ' + e.message); }
finally { this.moduleDetailLoading = false; }
},
async savePrompt(p) {
this.promptSaving = p.id;
const token = localStorage.getItem('authToken');
try {
await fetch('/api/admin/prompt-configs/' + p.id, {
method: 'PUT',
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify({ content: p.content, enabled: p.enabled, description: p.description, temperature: p.temperature, max_tokens: p.max_tokens })
});
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() {
const token = localStorage.getItem('authToken');
if (!token) {
window.location.href = '/login.html';
return;
}
fetch('/api/auth/me', {
headers: { 'Authorization': 'Bearer ' + token }
})
.then(response => response.ok ? response.json() : Promise.reject())
.then(data => {
this.currentUser = data.user;
this.isAdmin = data.user.role === 'admin';
this.isLoggedIn = true;
this.currentPage = 'overview';
this.fetchStats();
this.fetchModules();
this.fetchUpcomingPlan();
this.fetchTopTopics();
this.computeMonthlyGoal();
this.fetchTodos();
this.fetchSuggestions();
})
.catch(() => {
localStorage.removeItem('authToken');
window.location.href = '/login.html';
});
}
};
const app = Vue.createApp(App);
app.use(ElementPlus);
if (window.installIcons) { window.installIcons(app); }
if (window.installUniNav) { window.installUniNav(app); }
app.mount('#app');
</script>
<script src="ai-assistant.js"></script>
</body>
</html>