5caf7bc52e
- Add org_id auto-population to articles.py, publishing.py, metrics.py - Add org_id filtering to search_rankings.py geo endpoints - Add org_id to calendar.py create/update/delete endpoints - Add org_id to tasks.py create endpoints - Create factory.html content pipeline page (3-tab: 待创作/进行中/待审查) - Create insights.html analytics page (4-tab: 概览/搜索排名/GEO/平台对比)
1098 lines
46 KiB
HTML
1098 lines
46 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>宇之然内容创作平台 - 内容工厂</title>
|
|
<link rel="stylesheet" href="element-plus.css">
|
|
<link rel="stylesheet" href="theme-modern.css">
|
|
<style>
|
|
/* ========== Pipeline Header ========== */
|
|
.pipeline-header {
|
|
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
|
|
border-radius: var(--radius-lg);
|
|
padding: var(--spacing-lg) var(--spacing-xl);
|
|
margin-bottom: var(--spacing-lg);
|
|
color: #fff;
|
|
position: relative;
|
|
overflow: hidden;
|
|
}
|
|
.pipeline-header::before {
|
|
content: '';
|
|
position: absolute;
|
|
top: -50%;
|
|
right: -10%;
|
|
width: 300px;
|
|
height: 300px;
|
|
border-radius: 50%;
|
|
background: rgba(255,255,255,0.06);
|
|
}
|
|
.pipeline-header::after {
|
|
content: '';
|
|
position: absolute;
|
|
bottom: -60%;
|
|
left: 20%;
|
|
width: 200px;
|
|
height: 200px;
|
|
border-radius: 50%;
|
|
background: rgba(255,255,255,0.04);
|
|
}
|
|
.pipeline-title {
|
|
font-size: var(--font-size-h2);
|
|
font-weight: 700;
|
|
margin-bottom: var(--spacing-md);
|
|
position: relative;
|
|
z-index: 1;
|
|
}
|
|
.pipeline-title .subtitle {
|
|
font-size: var(--font-size-small);
|
|
font-weight: 400;
|
|
opacity: 0.8;
|
|
margin-left: var(--spacing-sm);
|
|
}
|
|
|
|
/* ========== Stage Hotbar ========== */
|
|
.stage-hotbar {
|
|
display: flex;
|
|
gap: 0;
|
|
position: relative;
|
|
z-index: 1;
|
|
overflow-x: auto;
|
|
padding-bottom: 4px;
|
|
}
|
|
.stage-hotbar-track {
|
|
display: flex;
|
|
align-items: center;
|
|
width: 100%;
|
|
min-width: 600px;
|
|
}
|
|
.stage-node {
|
|
display: flex;
|
|
align-items: center;
|
|
flex-shrink: 0;
|
|
}
|
|
.stage-dot {
|
|
width: 32px;
|
|
height: 32px;
|
|
border-radius: 50%;
|
|
background: rgba(255,255,255,0.2);
|
|
border: 2px solid rgba(255,255,255,0.4);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 14px;
|
|
font-weight: 700;
|
|
transition: all 0.3s;
|
|
flex-shrink: 0;
|
|
}
|
|
.stage-node.active .stage-dot {
|
|
background: #fff;
|
|
color: #2563eb;
|
|
border-color: #fff;
|
|
box-shadow: 0 0 0 4px rgba(255,255,255,0.2);
|
|
}
|
|
.stage-node.completed .stage-dot {
|
|
background: rgba(255,255,255,0.9);
|
|
border-color: rgba(255,255,255,0.9);
|
|
}
|
|
.stage-label {
|
|
font-size: var(--font-size-small);
|
|
margin-left: 8px;
|
|
opacity: 0.7;
|
|
white-space: nowrap;
|
|
transition: opacity 0.3s;
|
|
}
|
|
.stage-node.active .stage-label,
|
|
.stage-node.completed .stage-label {
|
|
opacity: 1;
|
|
font-weight: 600;
|
|
}
|
|
.stage-connector {
|
|
flex: 1;
|
|
height: 2px;
|
|
background: rgba(255,255,255,0.2);
|
|
min-width: 40px;
|
|
position: relative;
|
|
}
|
|
.stage-connector.completed {
|
|
background: rgba(255,255,255,0.8);
|
|
}
|
|
.stage-connector.active {
|
|
background: linear-gradient(90deg, rgba(255,255,255,0.8), rgba(255,255,255,0.2));
|
|
animation: pipelineFlow 2s linear infinite;
|
|
}
|
|
@keyframes pipelineFlow {
|
|
0% { background-position: 0 0; }
|
|
100% { background-position: 40px 0; }
|
|
}
|
|
|
|
/* ========== Tab Pipeline ========== */
|
|
.pipeline-tabs {
|
|
display: flex;
|
|
gap: var(--spacing-sm);
|
|
margin-bottom: var(--spacing-lg);
|
|
background: var(--color-bg-raised);
|
|
border-radius: var(--radius-md);
|
|
padding: 6px;
|
|
box-shadow: var(--shadow-sm);
|
|
}
|
|
.pipeline-tab {
|
|
flex: 1;
|
|
text-align: center;
|
|
padding: 12px 16px;
|
|
border-radius: var(--radius-sm);
|
|
cursor: pointer;
|
|
font-size: var(--font-size-body);
|
|
font-weight: 600;
|
|
color: var(--color-text-secondary);
|
|
transition: all 0.3s var(--ease-out);
|
|
position: relative;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 8px;
|
|
user-select: none;
|
|
}
|
|
.pipeline-tab:hover {
|
|
color: var(--color-text-primary);
|
|
background: var(--color-bg-subtle);
|
|
}
|
|
.pipeline-tab.active {
|
|
background: #2563eb;
|
|
color: #fff;
|
|
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.3);
|
|
}
|
|
.pipeline-tab .tab-count {
|
|
background: rgba(255,255,255,0.25);
|
|
padding: 2px 8px;
|
|
border-radius: 10px;
|
|
font-size: 12px;
|
|
font-weight: 700;
|
|
min-width: 24px;
|
|
text-align: center;
|
|
}
|
|
.pipeline-tab:not(.active) .tab-count {
|
|
background: var(--color-bg);
|
|
color: var(--color-text-secondary);
|
|
}
|
|
.pipeline-tab .tab-icon {
|
|
font-size: 16px;
|
|
}
|
|
|
|
/* ========== Pipeline Stage Cards ========== */
|
|
.pipeline-section {
|
|
display: none;
|
|
animation: fadeSlideIn 0.3s var(--ease-out);
|
|
}
|
|
.pipeline-section.active {
|
|
display: block;
|
|
}
|
|
@keyframes fadeSlideIn {
|
|
from { opacity: 0; transform: translateY(8px); }
|
|
to { opacity: 1; transform: translateY(0); }
|
|
}
|
|
|
|
/* ========== Topic Cards (待创作) ========== */
|
|
.topic-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
|
gap: var(--spacing-md);
|
|
}
|
|
.topic-card {
|
|
background: var(--color-bg-raised);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-md);
|
|
padding: var(--spacing-md);
|
|
transition: all 0.3s var(--ease-out);
|
|
position: relative;
|
|
overflow: hidden;
|
|
}
|
|
.topic-card::before {
|
|
content: '';
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
height: 3px;
|
|
background: linear-gradient(90deg, #2563eb, #7c3aed);
|
|
opacity: 0;
|
|
transition: opacity 0.3s;
|
|
}
|
|
.topic-card:hover {
|
|
border-color: #2563eb;
|
|
box-shadow: var(--shadow-lg);
|
|
transform: translateY(-2px);
|
|
}
|
|
.topic-card:hover::before {
|
|
opacity: 1;
|
|
}
|
|
.topic-card-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: flex-start;
|
|
margin-bottom: var(--spacing-sm);
|
|
gap: 8px;
|
|
}
|
|
.topic-card-id {
|
|
font-size: var(--font-size-caption);
|
|
font-weight: 700;
|
|
color: #2563eb;
|
|
background: rgba(37,99,235,0.08);
|
|
padding: 2px 8px;
|
|
border-radius: 4px;
|
|
flex-shrink: 0;
|
|
}
|
|
.topic-card-title {
|
|
font-size: var(--font-size-body);
|
|
font-weight: 600;
|
|
color: var(--color-text-primary);
|
|
line-height: 1.5;
|
|
flex: 1;
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 2;
|
|
-webkit-box-orient: vertical;
|
|
overflow: hidden;
|
|
}
|
|
.topic-card-fields {
|
|
display: flex;
|
|
gap: 6px;
|
|
flex-wrap: wrap;
|
|
margin-bottom: var(--spacing-sm);
|
|
}
|
|
.topic-card-meta {
|
|
display: flex;
|
|
gap: var(--spacing-md);
|
|
font-size: var(--font-size-caption);
|
|
color: var(--color-text-secondary);
|
|
margin-bottom: var(--spacing-md);
|
|
flex-wrap: wrap;
|
|
}
|
|
.topic-card-actions {
|
|
display: flex;
|
|
gap: 8px;
|
|
padding-top: var(--spacing-sm);
|
|
border-top: 1px solid var(--color-border);
|
|
}
|
|
|
|
/* ========== Running Task Cards ========== */
|
|
.task-flow {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--spacing-md);
|
|
}
|
|
.task-flow-card {
|
|
background: var(--color-bg-raised);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-md);
|
|
overflow: hidden;
|
|
transition: all 0.3s var(--ease-out);
|
|
}
|
|
.task-flow-card:hover {
|
|
box-shadow: var(--shadow-md);
|
|
}
|
|
.task-flow-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: var(--spacing-md);
|
|
cursor: pointer;
|
|
gap: 12px;
|
|
}
|
|
.task-flow-title {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
.task-flow-topic {
|
|
font-size: var(--font-size-body);
|
|
font-weight: 600;
|
|
color: var(--color-text-primary);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
.task-flow-stage {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
padding: 4px 12px;
|
|
border-radius: 16px;
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
flex-shrink: 0;
|
|
}
|
|
.task-flow-body {
|
|
padding: 0 var(--spacing-md) var(--spacing-md);
|
|
display: none;
|
|
}
|
|
.task-flow-card.expanded .task-flow-body {
|
|
display: block;
|
|
}
|
|
.task-flow-card.expanded .task-flow-header {
|
|
background: var(--color-bg-subtle);
|
|
}
|
|
.stage-timeline {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0;
|
|
margin-bottom: var(--spacing-md);
|
|
overflow-x: auto;
|
|
padding-bottom: 4px;
|
|
}
|
|
.timeline-stage {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
flex-shrink: 0;
|
|
min-width: 80px;
|
|
}
|
|
.timeline-dot {
|
|
width: 28px;
|
|
height: 28px;
|
|
border-radius: 50%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 12px;
|
|
margin-bottom: 4px;
|
|
transition: all 0.3s;
|
|
}
|
|
.timeline-dot.done {
|
|
background: #67c23a;
|
|
color: #fff;
|
|
}
|
|
.timeline-dot.current {
|
|
background: #2563eb;
|
|
color: #fff;
|
|
animation: pulse-glow 2s infinite;
|
|
}
|
|
.timeline-dot.waiting {
|
|
background: #f0f0f0;
|
|
color: #c0c4cc;
|
|
border: 2px solid #e0e0e0;
|
|
}
|
|
.timeline-label {
|
|
font-size: 11px;
|
|
color: var(--color-text-secondary);
|
|
text-align: center;
|
|
white-space: nowrap;
|
|
}
|
|
.timeline-connector {
|
|
width: 30px;
|
|
height: 2px;
|
|
background: #e0e0e0;
|
|
flex-shrink: 0;
|
|
margin-bottom: 20px;
|
|
}
|
|
.timeline-connector.done {
|
|
background: #67c23a;
|
|
}
|
|
.task-progress-bar {
|
|
margin-bottom: var(--spacing-sm);
|
|
}
|
|
.task-message {
|
|
font-size: var(--font-size-small);
|
|
color: var(--color-text-regular);
|
|
padding: 6px 10px;
|
|
background: var(--color-bg);
|
|
border-radius: 6px;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
/* ========== Review Cards ========== */
|
|
.review-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
|
gap: var(--spacing-md);
|
|
}
|
|
.review-card {
|
|
background: var(--color-bg-raised);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-md);
|
|
padding: var(--spacing-md);
|
|
transition: all 0.3s var(--ease-out);
|
|
position: relative;
|
|
}
|
|
.review-card:hover {
|
|
border-color: #e6a23c;
|
|
box-shadow: var(--shadow-md);
|
|
transform: translateY(-2px);
|
|
}
|
|
.review-card-platform {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
padding: 3px 10px;
|
|
border-radius: 12px;
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
margin-bottom: var(--spacing-sm);
|
|
}
|
|
.platform-zhihu { background: #e8f4fd; color: #2563eb; }
|
|
.platform-wechat { background: #f0fdf4; color: #16a34a; }
|
|
.platform-xiaohongshu { background: #fef2f2; color: #dc2626; }
|
|
.review-card-title {
|
|
font-size: var(--font-size-body);
|
|
font-weight: 600;
|
|
color: var(--color-text-primary);
|
|
margin-bottom: var(--spacing-sm);
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 2;
|
|
-webkit-box-orient: vertical;
|
|
overflow: hidden;
|
|
}
|
|
.review-stats {
|
|
display: flex;
|
|
gap: var(--spacing-md);
|
|
font-size: var(--font-size-small);
|
|
color: var(--color-text-secondary);
|
|
margin-bottom: var(--spacing-md);
|
|
}
|
|
.review-score {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
}
|
|
.review-score .score-value {
|
|
font-size: var(--font-size-h3);
|
|
font-weight: 700;
|
|
}
|
|
.review-score.high .score-value { color: #67c23a; }
|
|
.review-score.mid .score-value { color: #e6a23c; }
|
|
.review-score.low .score-value { color: #f56c6c; }
|
|
.review-card-actions {
|
|
display: flex;
|
|
gap: 8px;
|
|
}
|
|
|
|
/* ========== Empty State ========== */
|
|
.pipeline-empty {
|
|
text-align: center;
|
|
padding: 60px 20px;
|
|
color: var(--color-text-secondary);
|
|
}
|
|
.pipeline-empty .empty-icon {
|
|
font-size: 48px;
|
|
margin-bottom: var(--spacing-md);
|
|
opacity: 0.5;
|
|
}
|
|
.pipeline-empty .empty-text {
|
|
font-size: var(--font-size-body);
|
|
margin-bottom: var(--spacing-sm);
|
|
}
|
|
.pipeline-empty .empty-hint {
|
|
font-size: var(--font-size-small);
|
|
color: var(--color-text-placeholder);
|
|
}
|
|
|
|
/* ========== Priority Badge ========== */
|
|
.priority-badge {
|
|
display: inline-block;
|
|
padding: 2px 8px;
|
|
border-radius: 10px;
|
|
font-size: 11px;
|
|
font-weight: 700;
|
|
}
|
|
.priority-high { background: #fef2f2; color: #dc2626; }
|
|
.priority-mid { background: #fefce8; color: #ca8a04; }
|
|
.priority-low { background: #f0fdf4; color: #16a34a; }
|
|
|
|
/* ========== Platforms Tags ========== */
|
|
.platforms-needed {
|
|
display: flex;
|
|
gap: 4px;
|
|
flex-wrap: wrap;
|
|
}
|
|
.platform-mini-tag {
|
|
padding: 1px 6px;
|
|
border-radius: 4px;
|
|
font-size: 10px;
|
|
font-weight: 600;
|
|
}
|
|
|
|
/* ========== Mobile ========== */
|
|
@media (max-width: 768px) {
|
|
.topic-grid, .review-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
.pipeline-header {
|
|
padding: var(--spacing-md);
|
|
}
|
|
.pipeline-tabs {
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
}
|
|
.pipeline-tab {
|
|
padding: 10px 12px;
|
|
}
|
|
.stage-hotbar-track {
|
|
min-width: unset;
|
|
}
|
|
.stage-connector {
|
|
min-width: 20px;
|
|
}
|
|
.stage-label {
|
|
display: none;
|
|
}
|
|
.task-flow-header {
|
|
flex-wrap: wrap;
|
|
}
|
|
}
|
|
|
|
/* ========== Refresh pulse ========== */
|
|
.auto-refresh-hint {
|
|
font-size: 12px;
|
|
color: var(--color-text-secondary);
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
}
|
|
.auto-refresh-hint .pulse {
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 50%;
|
|
background: #67c23a;
|
|
animation: pulse 2s infinite;
|
|
}
|
|
@keyframes pulse {
|
|
0%, 100% { opacity: 1; }
|
|
50% { opacity: 0.4; }
|
|
}
|
|
</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="factory" @navigate="redirectToPage" @logout="handleLogout">
|
|
</uni-nav>
|
|
|
|
<div class="main-content">
|
|
<main class="content-area">
|
|
<!-- Pipeline Header -->
|
|
<div class="pipeline-header">
|
|
<div class="pipeline-title">
|
|
内容工厂 <span class="subtitle">Content Pipeline</span>
|
|
</div>
|
|
<div class="stage-hotbar">
|
|
<div class="stage-hotbar-track">
|
|
<div class="stage-node completed">
|
|
<div class="stage-dot">1</div>
|
|
<span class="stage-label">选题</span>
|
|
</div>
|
|
<div class="stage-connector completed"></div>
|
|
<div class="stage-node completed">
|
|
<div class="stage-dot">2</div>
|
|
<span class="stage-label">大纲</span>
|
|
</div>
|
|
<div class="stage-connector completed"></div>
|
|
<div class="stage-node active">
|
|
<div class="stage-dot">3</div>
|
|
<span class="stage-label">创作</span>
|
|
</div>
|
|
<div class="stage-connector active"></div>
|
|
<div class="stage-node">
|
|
<div class="stage-dot">4</div>
|
|
<span class="stage-label">合规</span>
|
|
</div>
|
|
<div class="stage-connector"></div>
|
|
<div class="stage-node">
|
|
<div class="stage-dot">5</div>
|
|
<span class="stage-label">发布</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Pipeline Tabs -->
|
|
<div class="pipeline-tabs">
|
|
<div class="pipeline-tab" :class="{ active: activeTab === 'create' }" @click="activeTab = 'create'">
|
|
<span class="tab-icon">📋</span>
|
|
待创作
|
|
<span class="tab-count">{{ readyTopics.length }}</span>
|
|
</div>
|
|
<div class="pipeline-tab" :class="{ active: activeTab === 'running' }" @click="activeTab = 'running'">
|
|
<span class="tab-icon">⚙️</span>
|
|
进行中
|
|
<span class="tab-count">{{ runningTasks.length }}</span>
|
|
</div>
|
|
<div class="pipeline-tab" :class="{ active: activeTab === 'review' }" @click="activeTab = 'review'">
|
|
<span class="tab-icon">🔎</span>
|
|
待审查
|
|
<span class="tab-count">{{ reviewArticles.length }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tab 1: 待创作 (Ready Topics) -->
|
|
<div class="pipeline-section" :class="{ active: activeTab === 'create' }">
|
|
<div class="toolbar" style="margin-bottom: 16px;">
|
|
<el-button type="primary" size="small" @click="batchCreate" :disabled="selectedReadyTopics.length === 0" :loading="creatingBatch">
|
|
<el-icon style="vertical-align:-2px;"><IconPromotion /></el-icon>
|
|
批量创作 ({{ selectedReadyTopics.length }})
|
|
</el-button>
|
|
<el-button size="small" @click="loadReadyTopics">
|
|
<el-icon style="vertical-align:-2px;"><IconRefresh /></el-icon> 刷新
|
|
</el-button>
|
|
<span class="auto-refresh-hint">
|
|
<span class="pulse"></span>
|
|
自动刷新中
|
|
</span>
|
|
</div>
|
|
|
|
<div v-if="loadingReady" 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="readyTopics.length === 0" class="pipeline-empty">
|
|
<div class="empty-icon">📚</div>
|
|
<div class="empty-text">暂无待创作选题</div>
|
|
<div class="empty-hint">选题采集完成后会自动出现在这里</div>
|
|
</div>
|
|
<div v-else class="topic-grid">
|
|
<div v-for="topic in readyTopics" :key="topic.id" class="topic-card" :class="{ selected: selectedReadyTopics.includes(topic.id) }">
|
|
<div class="topic-card-header">
|
|
<span class="topic-card-id">{{ topic.id }}</span>
|
|
<span :class="['priority-badge', topic.priority === '高' ? 'priority-high' : topic.priority === '低' ? 'priority-low' : 'priority-mid']">
|
|
{{ topic.priority || '中' }}
|
|
</span>
|
|
</div>
|
|
<div class="topic-card-title">{{ topic.title }}</div>
|
|
<div class="topic-card-fields">
|
|
<el-tag v-if="topic.field" size="small" type="info" effect="plain">{{ topic.field }}</el-tag>
|
|
<el-tag v-if="topic.format" size="small" type="success" effect="plain">{{ topic.format }}</el-tag>
|
|
<el-tag v-if="topic.total_score !== undefined && topic.total_score !== null" size="small" type="warning" effect="plain">
|
|
评分 {{ topic.total_score.toFixed(1) }}
|
|
</el-tag>
|
|
</div>
|
|
<div class="topic-card-meta">
|
|
<span>📅 {{ formatDate(topic.created_at) }}</span>
|
|
<span v-if="topic.priority_score">分数: {{ topic.priority_score }}</span>
|
|
</div>
|
|
<div class="topic-card-actions">
|
|
<el-checkbox v-model="selectedReadyTopics" :label="topic.id" style="flex-shrink:0;"></el-checkbox>
|
|
<el-button size="small" type="primary" @click="startCreate(topic)" :loading="creating[topic.id]" style="flex:1;">
|
|
🔧 开始创作
|
|
</el-button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tab 2: 进行中 (Running Tasks) -->
|
|
<div class="pipeline-section" :class="{ active: activeTab === 'running' }">
|
|
<div class="toolbar" style="margin-bottom: 16px;">
|
|
<el-button size="small" @click="loadRunningTasks">
|
|
<el-icon style="vertical-align:-2px;"><IconRefresh /></el-icon> 刷新
|
|
</el-button>
|
|
<span class="auto-refresh-hint">
|
|
<span class="pulse"></span>
|
|
每30秒自动刷新
|
|
</span>
|
|
</div>
|
|
|
|
<div v-if="loadingRunning" 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="runningTasks.length === 0" class="pipeline-empty">
|
|
<div class="empty-icon">🚀</div>
|
|
<div class="empty-text">暂无进行中的任务</div>
|
|
<div class="empty-hint">点击「待创作」标签页开始创作选题</div>
|
|
</div>
|
|
<div v-else class="task-flow">
|
|
<div v-for="task in runningTasks" :key="task.task_id" class="task-flow-card" :class="{ expanded: expandedTasks[task.task_id] }">
|
|
<div class="task-flow-header" @click="expandedTasks[task.task_id] = !expandedTasks[task.task_id]">
|
|
<div class="task-flow-title">
|
|
<span class="task-flow-topic">{{ task.topic_title || task.topic_id || '通用任务' }}</span>
|
|
<span class="task-flow-stage" :class="{'stage-creator': task.stage==='creator','stage-optimize': task.stage==='optimizer','stage-publish': task.stage==='publish'}">
|
|
{{ getStageLabel(task.stage) }}
|
|
</span>
|
|
<el-tag size="small" :type="task.status==='running'?'success':task.status==='pending'?'info':'warning'" effect="plain">
|
|
{{ getStatusLabel(task.status) }}
|
|
</el-tag>
|
|
</div>
|
|
<div style="display:flex;align-items:center;gap:12px;flex-shrink:0;">
|
|
<span style="font-size:13px;font-weight:600;color:#2563eb;">{{ task.progress }}%</span>
|
|
<el-button v-if="task.status==='running'" size="small" type="danger" @click.stop="cancelTask(task.task_id)" style="padding:4px 8px;font-size:12px;">取消</el-button>
|
|
<span style="font-size:12px;color:#909399;">{{ expandedTasks[task.task_id] ? '收起' : '展开' }}</span>
|
|
</div>
|
|
</div>
|
|
<div class="task-flow-body">
|
|
<!-- Stage Timeline -->
|
|
<div class="stage-timeline">
|
|
<div class="timeline-stage" v-for="(stg, idx) in stageOrder" :key="stg">
|
|
<div class="timeline-dot" :class="getStageStatus(task, stg)">
|
|
<span v-if="getStageStatus(task, stg)==='done'">✓</span>
|
|
<span v-else-if="getStageStatus(task, stg)==='current'">⇄</span>
|
|
<span v-else>{{ idx + 1 }}</span>
|
|
</div>
|
|
<div class="timeline-label">{{ stg }}</div>
|
|
<div v-if="idx < stageOrder.length - 1" class="timeline-connector" :class="{ done: isStageDone(task, stg) }"></div>
|
|
</div>
|
|
</div>
|
|
<!-- Progress Bar -->
|
|
<div class="task-progress-bar">
|
|
<el-progress :percentage="task.progress || 0" :stroke-width="16" :status="task.status==='failed'?'exception':''" :text-inside="false"></el-progress>
|
|
</div>
|
|
<!-- Message -->
|
|
<div v-if="task.message" class="task-message">
|
|
<span style="color:#909399;margin-right:6px;">💬</span>{{ task.message }}
|
|
</div>
|
|
<!-- Error -->
|
|
<div v-if="task.error_msg" style="padding:8px 12px;background:#fef0f0;border-radius:6px;font-size:13px;color:#f56c6c;border-left:3px solid #f56c6c;margin-top:8px;">
|
|
<span style="font-weight:600;">错误:</span> {{ task.error_msg }}
|
|
</div>
|
|
<!-- Meta -->
|
|
<div style="display:flex;gap:16px;font-size:12px;color:#909399;margin-top:12px;flex-wrap:wrap;">
|
|
<span>ID: {{ task.task_id.slice(0,16) }}</span>
|
|
<span v-if="task.created_by">创建人: {{ task.created_by }}</span>
|
|
<span v-if="task.started_at">开始: {{ formatDate(task.started_at) }}</span>
|
|
<span v-if="task.duration">耗时: {{ task.duration }}秒</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tab 3: 待审查 (Pending Review) -->
|
|
<div class="pipeline-section" :class="{ active: activeTab === 'review' }">
|
|
<div class="toolbar" style="margin-bottom: 16px;">
|
|
<el-button size="small" @click="loadReviewArticles">
|
|
<el-icon style="vertical-align:-2px;"><IconRefresh /></el-icon> 刷新
|
|
</el-button>
|
|
<el-select v-model="reviewPlatformFilter" placeholder="全部平台" size="small" style="width:140px;" @change="loadReviewArticles">
|
|
<el-option label="全部平台" value=""></el-option>
|
|
<el-option label="知乎" value="zhihu"></el-option>
|
|
<el-option label="微信公众号" value="wechat"></el-option>
|
|
<el-option label="小红书" value="xiaohongshu"></el-option>
|
|
</el-select>
|
|
</div>
|
|
|
|
<div v-if="loadingReview" 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="reviewArticles.length === 0" class="pipeline-empty">
|
|
<div class="empty-icon">📝</div>
|
|
<div class="empty-text">暂无待审查文章</div>
|
|
<div class="empty-hint">创作完成后文章会自动出现在这里</div>
|
|
</div>
|
|
<div v-else class="review-grid">
|
|
<div v-for="article in filteredReviewArticles" :key="article.id" class="review-card">
|
|
<div :class="['review-card-platform', 'platform-' + article.platform]">
|
|
{{ platformEmoji(article.platform) }} {{ platformLabel(article.platform) }}
|
|
</div>
|
|
<div class="review-card-title">{{ article.topic_title || article.topic_id }}</div>
|
|
<div class="review-stats">
|
|
<span>📝 {{ article.word_count || 0 }} 字</span>
|
|
<span>📅 {{ formatDate(article.created_at) }}</span>
|
|
</div>
|
|
<div class="review-score" :class="getScoreClass(article.compliance_score)">
|
|
<span style="font-size:12px;color:#909399;">合规分</span>
|
|
<span class="score-value">{{ article.compliance_score ?? '--' }}</span>
|
|
</div>
|
|
<div class="review-card-actions" style="margin-top:12px;">
|
|
<el-button size="small" type="primary" @click="previewArticle(article)" style="flex:1;">
|
|
👁 审阅
|
|
</el-button>
|
|
<el-button size="small" @click="goToArticles" style="padding:6px 12px;">
|
|
文章管理
|
|
</el-button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
</div>
|
|
|
|
<!-- Article Preview Dialog -->
|
|
<el-dialog v-model="previewVisible" :title="'审阅: ' + (previewArticleData?.topic_title || previewArticleData?.id || '')" width="85%" class="preview-dialog-custom" :fullscreen="previewFullscreen" close-on-press-escape :lock-scroll="false">
|
|
<div v-if="previewArticleData">
|
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;flex-wrap:wrap;gap:8px;">
|
|
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
|
|
<el-tag :type="getScoreTagType(previewArticleData.compliance_score)" size="small">
|
|
合规分: {{ previewArticleData.compliance_score ?? '--' }}
|
|
</el-tag>
|
|
<span style="font-size:13px;color:#909399;">
|
|
{{ platformLabel(previewArticleData.platform) }} · {{ previewArticleData.word_count ?? '--' }} 字
|
|
</span>
|
|
</div>
|
|
<div style="display:flex;gap:8px;">
|
|
<el-button size="small" @click="togglePreviewFullscreen">{{ previewFullscreen ? '退出全屏' : '全屏' }}</el-button>
|
|
<el-button v-if="previewFullscreen" size="small" type="danger" @click="previewVisible = false">关闭</el-button>
|
|
</div>
|
|
</div>
|
|
<div v-if="previewArticleData.html_content" style="border:1px solid #ebeef5;border-radius:8px;overflow:hidden;background:#fff;">
|
|
<iframe :srcdoc="previewArticleData.html_content" class="preview-iframe" sandbox></iframe>
|
|
</div>
|
|
<div v-else style="padding:40px;text-align:center;color:#909399;">暂无 HTML 内容</div>
|
|
</div>
|
|
</el-dialog>
|
|
</div>
|
|
|
|
<script src="vue.global.prod.js"></script>
|
|
<script src="icon-components.js"></script>
|
|
<script src="element-plus.full.js"></script>
|
|
<script>
|
|
const FactoryApp = {
|
|
data() {
|
|
return {
|
|
isLoggedIn: false,
|
|
isAdmin: false,
|
|
currentUser: { username: '' },
|
|
activeTab: 'create',
|
|
// Ready topics
|
|
readyTopics: [],
|
|
selectedReadyTopics: [],
|
|
loadingReady: false,
|
|
creating: {},
|
|
creatingBatch: false,
|
|
// Running tasks
|
|
runningTasks: [],
|
|
loadingRunning: false,
|
|
expandedTasks: {},
|
|
refreshInterval: null,
|
|
// Review articles
|
|
reviewArticles: [],
|
|
loadingReview: false,
|
|
reviewPlatformFilter: '',
|
|
previewVisible: false,
|
|
previewArticleData: null,
|
|
previewFullscreen: false,
|
|
// Pipeline stages
|
|
stageOrder: ['research', 'outline', 'writer', 'optimizer', 'format', 'publish'],
|
|
};
|
|
},
|
|
computed: {
|
|
filteredReviewArticles() {
|
|
if (!this.reviewPlatformFilter) return this.reviewArticles;
|
|
return this.reviewArticles.filter(a => a.platform === this.reviewPlatformFilter);
|
|
}
|
|
},
|
|
methods: {
|
|
getToken() { return localStorage.getItem('authToken'); },
|
|
async api(url, opts = {}) {
|
|
const token = this.getToken();
|
|
if (!token) { this.$message.error('请先登录'); setTimeout(() => window.location.href = '/login.html', 1500); return null; }
|
|
const res = await fetch(url, { headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json', ...opts.headers }, ...opts });
|
|
if (!res.ok) {
|
|
const data = await res.json().catch(() => ({}));
|
|
throw new Error(data.detail || `请求失败: ${res.status}`);
|
|
}
|
|
return res.json();
|
|
},
|
|
formatDate(dateStr) {
|
|
if (!dateStr) return '-';
|
|
try {
|
|
return new Date(dateStr.replace(' ', 'T')).toLocaleString('zh-CN', {
|
|
year: 'numeric', month: '2-digit', day: '2-digit',
|
|
hour: '2-digit', minute: '2-digit'
|
|
});
|
|
} catch (e) { return dateStr; }
|
|
},
|
|
platformLabel(p) { return { zhihu: '知乎', wechat: '微信公众号', xiaohongshu: '小红书' }[p] || p; },
|
|
platformEmoji(p) { return { zhihu: '🧰', wechat: '📲', xiaohongshu: '🔥' }[p] || ''; },
|
|
getStatusLabel(status) {
|
|
const map = { pending: '等待中', running: '运行中', completed: '已完成', failed: '失败', cancelled: '已取消' };
|
|
return map[status] || status;
|
|
},
|
|
getStageLabel(stage) {
|
|
const map = { research: '调研', outline: '大纲', writer: '创作', optimizer: '优化', format: '排版', publish: '发布', creator: '创作' };
|
|
return map[stage] || stage;
|
|
},
|
|
getScoreClass(score) {
|
|
if (score === null || score === undefined) return '';
|
|
if (score >= 80) return 'high';
|
|
if (score >= 60) return 'mid';
|
|
return 'low';
|
|
},
|
|
getScoreTagType(score) {
|
|
if (score === null || score === undefined) return 'info';
|
|
if (score >= 80) return 'success';
|
|
if (score >= 60) return 'warning';
|
|
return 'danger';
|
|
},
|
|
// ===================== Ready Topics =====================
|
|
async loadReadyTopics() {
|
|
this.loadingReady = true;
|
|
try {
|
|
const data = await this.api('/api/topics?status=ready&limit=50');
|
|
this.readyTopics = data || [];
|
|
this.selectedReadyTopics = [];
|
|
} catch (error) {
|
|
console.error('获取待创作选题失败:', error);
|
|
this.$message.error('获取选题失败: ' + error.message);
|
|
this.readyTopics = [];
|
|
} finally {
|
|
this.loadingReady = false;
|
|
}
|
|
},
|
|
async startCreate(topic) {
|
|
this.creating[topic.id] = true;
|
|
try {
|
|
const data = await this.api('/api/tasks/run-creator?topic_id=' + topic.id, { method: 'POST' });
|
|
this.$message.success(`创作任务已启动: ${topic.title}`);
|
|
this.selectedReadyTopics = this.selectedReadyTopics.filter(id => id !== topic.id);
|
|
setTimeout(() => this.loadReadyTopics(), 2000);
|
|
} catch (error) {
|
|
this.$message.error('创作失败: ' + error.message);
|
|
} finally {
|
|
this.creating[topic.id] = false;
|
|
}
|
|
},
|
|
async batchCreate() {
|
|
if (this.selectedReadyTopics.length === 0) return;
|
|
this.creatingBatch = true;
|
|
let count = 0;
|
|
for (const id of this.selectedReadyTopics) {
|
|
try {
|
|
await this.api('/api/tasks/run-creator?topic_id=' + id, { method: 'POST' });
|
|
count++;
|
|
} catch (e) {
|
|
console.error('创作失败:', id, e);
|
|
}
|
|
}
|
|
this.$message.success(`已提交 ${count}/${this.selectedReadyTopics.length} 个创作任务`);
|
|
this.selectedReadyTopics = [];
|
|
this.creatingBatch = false;
|
|
setTimeout(() => this.loadReadyTopics(), 2000);
|
|
},
|
|
// ===================== Running Tasks =====================
|
|
async loadRunningTasks() {
|
|
this.loadingRunning = true;
|
|
try {
|
|
const data = await this.api('/api/tasks?status=running&limit=50');
|
|
this.runningTasks = data || [];
|
|
} catch (error) {
|
|
console.error('获取运行中任务失败:', error);
|
|
this.$message.error('获取任务失败: ' + error.message);
|
|
this.runningTasks = [];
|
|
} finally {
|
|
this.loadingRunning = false;
|
|
}
|
|
},
|
|
async cancelTask(taskId) {
|
|
try {
|
|
await this.$confirm('确定取消此任务?', '提示', { type: 'warning' });
|
|
await this.api('/api/tasks/' + taskId, { method: 'DELETE' });
|
|
this.$message.success('任务已取消');
|
|
this.loadRunningTasks();
|
|
} catch (e) {
|
|
if (e !== 'cancel') this.$message.error('取消失败: ' + (e.message || '未知错误'));
|
|
}
|
|
},
|
|
getStageStatus(task, stageName) {
|
|
// Determine stage status based on task progress and stage
|
|
const stageIndex = this.stageOrder.indexOf(stageName);
|
|
if (stageIndex === -1) return 'waiting';
|
|
// If task has progressed past this stage
|
|
if (task.progress >= ((stageIndex + 1) * (100 / this.stageOrder.length))) {
|
|
return 'done';
|
|
}
|
|
if (task.progress >= (stageIndex * (100 / this.stageOrder.length))) {
|
|
return 'current';
|
|
}
|
|
return 'waiting';
|
|
},
|
|
isStageDone(task, stageName) {
|
|
return this.getStageStatus(task, stageName) === 'done';
|
|
},
|
|
// ===================== Review Articles =====================
|
|
async loadReviewArticles() {
|
|
this.loadingReview = true;
|
|
try {
|
|
const data = await this.api('/api/articles/list');
|
|
// Filter for draft/reviewed articles that need review
|
|
const allArticles = (data && data.articles) || [];
|
|
this.reviewArticles = allArticles.filter(a => a.status === 'draft' || a.status === 'reviewed');
|
|
} catch (error) {
|
|
console.error('获取待审查文章失败:', error);
|
|
this.$message.error('获取文章失败: ' + error.message);
|
|
this.reviewArticles = [];
|
|
} finally {
|
|
this.loadingReview = false;
|
|
}
|
|
},
|
|
async previewArticle(article) {
|
|
this.previewVisible = true;
|
|
this.previewFullscreen = false;
|
|
this.previewArticleData = null;
|
|
try {
|
|
const data = await this.api('/api/articles/detail/' + article.id);
|
|
this.previewArticleData = data;
|
|
} catch (error) {
|
|
this.$message.error('加载文章详情失败: ' + error.message);
|
|
this.previewVisible = false;
|
|
}
|
|
},
|
|
togglePreviewFullscreen() {
|
|
this.previewFullscreen = !this.previewFullscreen;
|
|
this.$nextTick(() => {
|
|
const overlays = document.querySelectorAll('.el-overlay');
|
|
const overlay = overlays[overlays.length - 1];
|
|
if (overlay) overlay.style.zIndex = this.previewFullscreen ? '100000' : '';
|
|
const dialog = document.querySelector('.preview-dialog-custom');
|
|
if (dialog) dialog.style.zIndex = this.previewFullscreen ? '100001' : '';
|
|
});
|
|
},
|
|
goToArticles() {
|
|
window.location.href = '/articles.html';
|
|
},
|
|
handleLogout() {
|
|
localStorage.removeItem('authToken');
|
|
localStorage.removeItem('userRole');
|
|
localStorage.removeItem('currentUser');
|
|
window.location.href = '/login.html';
|
|
},
|
|
redirectToPage(page) {
|
|
window.location.href = page.startsWith('/') ? page : '/' + page;
|
|
}
|
|
},
|
|
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;
|
|
// Load all three tabs data
|
|
this.loadReadyTopics();
|
|
this.loadRunningTasks();
|
|
this.loadReviewArticles();
|
|
// Auto-refresh running tasks every 30s
|
|
this.refreshInterval = setInterval(() => {
|
|
if (this.activeTab === 'running') {
|
|
this.loadRunningTasks();
|
|
}
|
|
}, 30000);
|
|
})
|
|
.catch(() => {
|
|
localStorage.removeItem('authToken');
|
|
localStorage.removeItem('userRole');
|
|
localStorage.removeItem('currentUser');
|
|
window.location.href = '/login.html';
|
|
});
|
|
},
|
|
beforeUnmount() {
|
|
if (this.refreshInterval) clearInterval(this.refreshInterval);
|
|
}
|
|
};
|
|
|
|
const app = Vue.createApp(FactoryApp);
|
|
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>
|