Files
yu-zhi-ran/platform/frontend/factory.html
T
Yuzhiran Dev 5caf7bc52e feat: Wave 2 - API org isolation + factory.html + insights.html
- 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/平台对比)
2026-06-17 16:13:48 +08:00

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">&#128203;</span>
待创作
<span class="tab-count">{{ readyTopics.length }}</span>
</div>
<div class="pipeline-tab" :class="{ active: activeTab === 'running' }" @click="activeTab = 'running'">
<span class="tab-icon">&#9881;&#65039;</span>
进行中
<span class="tab-count">{{ runningTasks.length }}</span>
</div>
<div class="pipeline-tab" :class="{ active: activeTab === 'review' }" @click="activeTab = 'review'">
<span class="tab-icon">&#128270;</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">&#128218;</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>&#128197; {{ 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;">
&#128295; 开始创作
</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">&#128640;</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'">&#10003;</span>
<span v-else-if="getStageStatus(task, stg)==='current'">&#8644;</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;">&#128172;</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">&#128221;</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>&#128221; {{ article.word_count || 0 }} 字</span>
<span>&#128197; {{ 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;">
&#128065; 审阅
</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: '&#129520;', wechat: '&#128242;', xiaohongshu: '&#128293;' }[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>