feat: 完善前端页面 - 新增数据分析、素材库、创作任务、平台配置页面

- 新增 metrics.html: 数据分析页面 (Dashboard, 趋势图, 平台对比, 选题推荐)
- 新增 assets.html: 素材库页面 (上传/管理/预览/标签)
- 新增 tasks.html: 创作任务页面 (任务列表/进度/详情)
- 新增 platforms.html: 平台配置页面 (知乎/微信/小红书格式规则)
- 更新导航组件: 添加新页面入口, 适配 H5 底部导航
- 修复 calendar.html: 使用本地 Vue/ElementPlus 资源
- 修复 assets.py: db.func.count -> sqlalchemy.func.count
- 新增 test_api_unit.py: 后端 API 单元测试
- 新增 test_frontend.sh: 前端页面完整性测试

PC/H5 双端适配, 所有页面统一使用 navbar + navigation 组件
This commit is contained in:
lt
2026-05-08 23:35:26 +08:00
parent cf5103bbca
commit 526278589b
9 changed files with 1430 additions and 4 deletions
+2 -1
View File
@@ -4,6 +4,7 @@ import hashlib
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query
from sqlalchemy.orm import Session
from sqlalchemy import func
from typing import List, Optional
from ..database import get_db
@@ -68,7 +69,7 @@ def get_counts(
):
total = db.query(MediaAsset).count()
by_type = {}
rows = db.query(MediaAsset.file_type, db.func.count(MediaAsset.id)).group_by(MediaAsset.file_type).all()
rows = db.query(MediaAsset.file_type, func.count(MediaAsset.id)).group_by(MediaAsset.file_type).all()
for ftype, cnt in rows:
by_type[ftype] = cnt
return {"total": total, "by_type": by_type}
+261
View File
@@ -0,0 +1,261 @@
<!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">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; }
.navbar { background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%); color: white; padding: 16px 24px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
.navbar-content { display: flex; justify-content: space-between; align-items: center; max-width: 1400px; margin: 0 auto; }
.navbar-title { font-size: 20px; font-weight: 600; }
.navbar-user { display: flex; align-items: center; gap: 16px; }
.user-info { display: flex; align-items: center; gap: 8px; }
.avatar { width: 32px; height: 32px; border-radius: 50%; background: rgba(255,255,255,0.2); display: flex; align-items: center; justify-content: center; font-size: 14px; }
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; width: 100%; }
.sidebar { width: 180px; background: white; padding: 12px; box-shadow: 2px 0 8px rgba(0,0,0,0.05); }
.content-area { flex: 1; padding: 24px; overflow-y: auto; }
.card { background: white; border-radius: 12px; padding: 24px; margin-bottom: 24px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
.mobile-nav { display: none; position: fixed; bottom: 0; left: 0; right: 0; background: white; box-shadow: 0 -2px 8px rgba(0,0,0,0.1); padding: 8px 0; z-index: 1000; }
@media (max-width: 768px) {
.sidebar { display: none; }
.mobile-nav { display: flex; }
.content-area { padding: 16px; padding-bottom: 80px; }
}
.asset-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px; }
.asset-item { border: 1px solid #ebeef5; border-radius: 8px; padding: 12px; transition: all 0.3s; cursor: pointer; }
.asset-item:hover { border-color: #409eff; box-shadow: 0 2px 12px rgba(64,158,255,0.2); }
.asset-thumb { width: 100%; height: 120px; border-radius: 4px; object-fit: cover; background: #f5f7fa; display: flex; align-items: center; justify-content: center; font-size: 32px; margin-bottom: 8px; }
.asset-name { font-size: 14px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.asset-meta { font-size: 12px; color: #909399; margin-top: 4px; }
.asset-tags { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 8px; }
.upload-area { border: 2px dashed #dcdfe6; border-radius: 12px; padding: 40px; text-align: center; cursor: pointer; transition: all 0.3s; }
.upload-area:hover { border-color: #409eff; background: #f0f9eb; }
.upload-icon { font-size: 48px; color: #c0c4cc; }
.filter-bar { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 16px; }
</style>
<script src="navigation-component.js"></script>
<script src="navbar-component.js"></script>
</head>
<body>
<div id="app">
<navbar-component title="素材库" :username="currentUser.username" :is-admin="isAdmin" @logout="handleLogout"></navbar-component>
<navigation-component current-page="assets" :is-admin="isAdmin" @navigate="redirectToPage"></navigation-component>
<div class="main-content">
<main class="content-area">
<h2 style="font-size: 24px; font-weight: 700; margin-bottom: 24px; color: #303133;">🖼️ 素材库</h2>
<div class="card">
<div class="filter-bar">
<el-input v-model="searchKeyword" placeholder="搜索素材..." style="width: 200px;" clearable @clear="loadAssets" @keyup.enter="loadAssets">
<template #prefix><span>🔍</span></template>
</el-input>
<el-select v-model="filterType" placeholder="文件类型" style="width: 120px;" clearable @change="loadAssets">
<el-option label="全部" value=""></el-option>
<el-option label="图片" value="image"></el-option>
<el-option label="视频" value="video"></el-option>
<el-option label="文档" value="document"></el-option>
</el-select>
<el-select v-model="filterTag" placeholder="标签筛选" style="width: 150px;" clearable @change="loadAssets">
<el-option v-for="tag in allTags" :key="tag" :label="tag" :value="tag"></el-option>
</el-select>
<el-button @click="loadAssets">🔄 刷新</el-button>
<el-button type="primary" @click="showUploadDialog = true">📤 上传素材</el-button>
</div>
<div style="margin-bottom: 16px; display: flex; gap: 20px; font-size: 14px; color: #606266;">
<span>总计: {{ assetStats.total }} 个</span>
<span v-for="(count, type) in assetStats.by_type" :key="type">{{ getTypeName(type) }}: {{ count }}</span>
</div>
<div v-if="loading" style="text-align: center; padding: 40px;">加载中...</div>
<div v-else-if="assets.length === 0" style="text-align: center; padding: 40px; color: #909399;">
<div style="font-size: 48px; margin-bottom: 16px;">📂</div>
<div>暂无素材,点击上方按钮上传</div>
</div>
<div v-else class="asset-grid">
<div v-for="asset in assets" :key="asset.id" class="asset-item" @click="previewAsset(asset)">
<div class="asset-thumb">
<template v-if="asset.file_type === 'image'">🖼️</template>
<template v-else-if="asset.file_type === 'video'">🎬</template>
<template v-else>📄</template>
</div>
<div class="asset-name">{{ asset.filename }}</div>
<div class="asset-meta">{{ formatSize(asset.size) }} | {{ formatDate(asset.created_at) }}</div>
<div class="asset-tags">
<el-tag v-for="tag in (asset.tags || [])" :key="tag" size="small" type="info">{{ tag }}</el-tag>
</div>
<div style="margin-top: 8px; display: flex; gap: 4px; justify-content: flex-end;">
<el-button size="small" type="primary" @click.stop="copyUrl(asset)">复制</el-button>
<el-button size="small" type="danger" @click.stop="deleteAsset(asset.id)">删除</el-button>
</div>
</div>
</div>
</div>
</main>
</div>
<el-dialog v-model="showUploadDialog" title="上传素材" width="500px">
<el-upload ref="uploadRef" drag :auto-upload="false" :limit="10" :on-change="handleFileChange" multiple accept="image/*,.pdf,.doc,.docx,.ppt,.pptx,.mp4,.mov,.avi">
<div class="upload-area">
<div class="upload-icon">📤</div>
<div style="margin-top: 12px; color: #606266;">将文件拖到此处,或<span style="color: #409eff;">点击上传</span></div>
<div style="font-size: 12px; color: #909399; margin-top: 8px;">支持: JPG, PNG, GIF, WebP, PDF, Word, PPT, MP4</div>
</div>
</el-upload>
<div style="margin-top: 16px;">
<el-input v-model="uploadTags" placeholder="标签(逗号分隔)" style="margin-bottom: 12px;"></el-input>
<el-input v-model="uploadAltText" placeholder="描述文字(可选)"></el-input>
</div>
<template #footer>
<el-button @click="showUploadDialog = false">取消</el-button>
<el-button type="primary" @click="uploadFiles" :loading="uploading">上传</el-button>
</template>
</el-dialog>
<el-dialog v-model="showPreviewDialog" title="素材预览" width="800px">
<div v-if="previewAssetData" style="text-align: center;">
<div style="font-size: 64px; margin-bottom: 16px;">
<template v-if="previewAssetData.file_type === 'image'">🖼️</template>
<template v-else-if="previewAssetData.file_type === 'video'">🎬</template>
<template v-else>📄</template>
</div>
<div style="font-size: 18px; font-weight: 600; margin-bottom: 8px;">{{ previewAssetData.filename }}</div>
<div style="color: #909399; font-size: 14px;">{{ formatSize(previewAssetData.size) }} | {{ formatDate(previewAssetData.created_at) }}</div>
<div v-if="previewAssetData.alt_text" style="margin-top: 12px; padding: 12px; background: #f5f7fa; border-radius: 8px;">{{ previewAssetData.alt_text }}</div>
<div class="asset-tags" style="justify-content: center; margin-top: 12px;">
<el-tag v-for="tag in (previewAssetData.tags || [])" :key="tag" size="small">{{ tag }}</el-tag>
</div>
<div style="margin-top: 16px; font-size: 13px; color: #606266;">使用次数: {{ previewAssetData.usage_count || 0 }}</div>
</div>
</el-dialog>
</div>
<script src="vue.global.prod.js"></script>
<script src="element-plus.full.js"></script>
<script>
const AssetsApp = {
data() {
return {
currentUser: { username: '' },
isAdmin: false,
isLoggedIn: false,
assets: [],
allTags: [],
assetStats: { total: 0, by_type: {} },
loading: false,
searchKeyword: '',
filterType: '',
filterTag: '',
showUploadDialog: false,
showPreviewDialog: false,
previewAssetData: null,
uploadFiles: [],
uploadTags: '',
uploadAltText: '',
uploading: false
}
},
methods: {
handleLogout() { localStorage.removeItem('authToken'); window.location.href = '/'; },
redirectToPage(page) { window.location.href = page; },
formatSize(bytes) {
if (!bytes) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
let i = 0;
while (bytes >= 1024 && i < units.length - 1) { bytes /= 1024; i++; }
return bytes.toFixed(1) + ' ' + units[i];
},
formatDate(dateStr) {
if (!dateStr) return '-';
return new Date(dateStr).toLocaleDateString('zh-CN');
},
getTypeName(type) {
const map = { 'image': '图片', 'video': '视频', 'document': '文档' };
return map[type] || type;
},
async loadAssets() {
this.loading = true;
try {
const token = localStorage.getItem('authToken');
let url = '/api/assets?limit=100';
if (this.filterType) url += '&file_type=' + this.filterType;
if (this.filterTag) url += '&tag=' + this.filterTag;
if (this.searchKeyword) url += '&search=' + encodeURIComponent(this.searchKeyword);
const res = await fetch(url, { headers: { 'Authorization': 'Bearer ' + token } });
if (res.ok) this.assets = await res.json();
} catch (e) { console.error(e); }
finally { this.loading = false; }
},
async loadTags() {
try {
const token = localStorage.getItem('authToken');
const res = await fetch('/api/assets/tags', { headers: { 'Authorization': 'Bearer ' + token } });
if (res.ok) this.allTags = await res.json();
} catch (e) { console.error(e); }
},
async loadStats() {
try {
const token = localStorage.getItem('authToken');
const res = await fetch('/api/assets/counts', { headers: { 'Authorization': 'Bearer ' + token } });
if (res.ok) this.assetStats = await res.json();
} catch (e) { console.error(e); }
},
handleFileChange(file, fileList) { this.uploadFiles = fileList; },
async uploadFiles() {
if (this.uploadFiles.length === 0) { this.$message.warning('请选择文件'); return; }
this.uploading = true;
try {
const token = localStorage.getItem('authToken');
for (const fileItem of this.uploadFiles) {
const formData = new FormData();
formData.append('file', fileItem.raw);
if (this.uploadTags) formData.append('tags', this.uploadTags);
if (this.uploadAltText) formData.append('alt_text', this.uploadAltText);
const res = await fetch('/api/assets/upload', { method: 'POST', headers: { 'Authorization': 'Bearer ' + token }, body: formData });
if (!res.ok) throw new Error('上传失败');
}
this.$message.success('上传成功');
this.showUploadDialog = false;
this.uploadFiles = [];
this.uploadTags = '';
this.uploadAltText = '';
this.loadAssets();
this.loadStats();
} catch (e) { this.$message.error(e.message); }
finally { this.uploading = false; }
},
previewAsset(asset) { this.previewAssetData = asset; this.showPreviewDialog = true; },
async copyUrl(asset) {
const url = '/content/images/' + asset.filename.split('/').pop();
navigator.clipboard.writeText(window.location.origin + url).then(() => this.$message.success('链接已复制'));
},
async deleteAsset(id) {
try {
await this.$confirm('确定删除该素材?', '提示', { type: 'warning' });
const token = localStorage.getItem('authToken');
const res = await fetch('/api/assets/' + id, { method: 'DELETE', headers: { 'Authorization': 'Bearer ' + token } });
if (res.ok) { this.$message.success('删除成功'); this.loadAssets(); this.loadStats(); }
} catch (e) { if (e !== 'cancel') this.$message.error(e.message || '删除失败'); }
}
},
mounted() {
const token = localStorage.getItem('authToken');
if (!token) { window.location.href = '/'; return; }
fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token } })
.then(r => r.ok ? r.json() : Promise.reject())
.then(data => {
this.currentUser = data.user;
this.isAdmin = data.user.role === 'admin';
this.isLoggedIn = true;
this.loadAssets();
this.loadTags();
this.loadStats();
})
.catch(() => { localStorage.removeItem('authToken'); window.location.href = '/'; });
}
};
const app = Vue.createApp(AssetsApp);
app.use(ElementPlus);
if (window.installNavbar) { window.installNavbar(app); }
if (window.installNavigation) { window.installNavigation(app); } else if (window.NavigationComponent) { app.component("navigation-component", window.NavigationComponent); }
app.mount('#app');
</script>
</body>
</html>
+296
View File
@@ -0,0 +1,296 @@
<!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">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; }
.navbar { background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%); color: white; padding: 16px 24px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
.navbar-content { display: flex; justify-content: space-between; align-items: center; max-width: 1400px; margin: 0 auto; }
.navbar-title { font-size: 20px; font-weight: 600; }
.navbar-user { display: flex; align-items: center; gap: 16px; }
.avatar { width: 32px; height: 32px; border-radius: 50%; background: rgba(255,255,255,0.2); display: flex; align-items: center; justify-content: center; font-size: 14px; }
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; width: 100%; }
.sidebar { width: 180px; background: white; padding: 12px; box-shadow: 2px 0 8px rgba(0,0,0,0.05); }
.content-area { flex: 1; padding: 32px; overflow-y: auto; }
.card { background: white; border-radius: 16px; padding: 24px; margin-bottom: 24px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
.mobile-nav { display: none; position: fixed; bottom: 0; left: 0; right: 0; background: white; box-shadow: 0 -2px 8px rgba(0,0,0,0.1); padding: 8px 0; z-index: 1000; }
@media (max-width: 768px) {
.sidebar { display: none; }
.mobile-nav { display: flex; }
.content-area { padding: 12px; padding-bottom: 80px; }
}
.calendar-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; flex-wrap: wrap; gap: 12px; }
.calendar-title { font-size: 28px; font-weight: 700; color: #303133; }
.calendar-nav { display: flex; align-items: center; gap: 16px; }
.calendar-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 8px; }
.calendar-weekday { text-align: center; font-weight: 600; color: #606266; padding: 12px; background: #f5f7fa; border-radius: 8px; }
.calendar-day { min-height: 100px; background: #fafafa; border-radius: 8px; padding: 8px; border: 1px solid #ebeef5; cursor: pointer; transition: all 0.2s; }
.calendar-day:hover { border-color: #409EFF; box-shadow: 0 2px 8px rgba(64,158,255,0.2); }
.calendar-day.other-month { opacity: 0.4; }
.calendar-day.today { border-color: #409EFF; background: #ecf5ff; }
.day-number { font-weight: 600; font-size: 14px; margin-bottom: 8px; color: #303133; }
.day-entries { display: flex; flex-direction: column; gap: 4px; }
.day-entry { font-size: 11px; padding: 4px 6px; border-radius: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: pointer; }
.day-entry.planned { background: #fdf6ec; color: #E6A23C; }
.day-entry.published { background: #f0f9eb; color: #67C23A; }
.day-entry.delayed { background: #fef0f0; color: #F56C6C; }
.day-entry.cancelled { background: #f4f4f5; color: #909399; }
.stats-bar { display: flex; gap: 24px; margin-bottom: 24px; flex-wrap: wrap; }
.stat-item { display: flex; align-items: center; gap: 8px; }
.stat-dot { width: 10px; height: 10px; border-radius: 50%; }
.stat-dot.planned { background: #E6A23C; }
.stat-dot.published { background: #67C23A; }
.stat-dot.delayed { background: #F56C6C; }
.stat-dot.cancelled { background: #909399; }
</style>
<script src="navigation-component.js"></script>
<script src="navbar-component.js"></script>
</head>
<body>
<div id="app">
<navbar-component
title="内容日历"
:username="currentUser.username"
:is-admin="isAdmin"
@logout="handleLogout"
></navbar-component>
<navigation-component
current-page="calendar"
:is-admin="isAdmin"
@navigate="redirectToPage"
></navigation-component>
<div class="main-content">
<main class="content-area">
<div class="card">
<div class="calendar-header">
<h2 class="calendar-title">📅 内容日历</h2>
<div class="calendar-nav">
<el-button @click="prevMonth" size="large"></el-button>
<span style="font-size: 20px; font-weight: 600;">{{ currentYear }}年 {{ currentMonth }}月</span>
<el-button @click="nextMonth" size="large"></el-button>
<el-button type="primary" @click="goToday">今天</el-button>
</div>
</div>
<div class="stats-bar">
<div class="stat-item"><span class="stat-dot planned"></span> 待发布: {{ stats.planned }}</div>
<div class="stat-item"><span class="stat-dot published"></span> 已发布: {{ stats.published }}</div>
<div class="stat-item"><span class="stat-dot delayed"></span> 延迟: {{ stats.delayed }}</div>
<div class="stat-item"><span class="stat-dot cancelled"></span> 取消: {{ stats.cancelled }}</div>
</div>
<div class="calendar-grid">
<div class="calendar-weekday" v-for="day in weekDays" :key="day">{{ day }}</div>
<div v-for="(day, index) in calendarDays" :key="index"
class="calendar-day"
:class="{ 'other-month': !day.isCurrentMonth, 'today': day.isToday }"
@click="openDayDialog(day)">
<div class="day-number">{{ day.day }}</div>
<div class="day-entries">
<div v-for="entry in day.entries" :key="entry.id"
class="day-entry"
:class="entry.status"
@click.stop="openEntryDialog(entry)">
{{ entry.platform_icon }} {{ entry.title || '未命名' }}
</div>
</div>
</div>
</div>
</div>
</main>
</div>
<el-dialog v-model="dayDialogVisible" :title="selectedDay ? `${selectedDay.year}-${selectedDay.month}-${selectedDay.day} 日程` : ''" width="500px">
<div v-if="selectedDay">
<el-button type="primary" @click="openCreateDialog" style="margin-bottom: 16px;">+ 添加日程</el-button>
<el-table :data="selectedDayEntries" stripe>
<el-table-column prop="title" label="标题" min-width="120"></el-table-column>
<el-table-column prop="platform" label="平台" width="100">
<template #default="scope">{{ platformName(scope.row.platform) }}</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="80">
<template #default="scope"><el-tag :type="statusType(scope.row.status)" size="small">{{ statusLabel(scope.row.status) }}</template>
</el-table-column>
<el-table-column label="操作" width="120">
<template #default="scope">
<el-button size="small" @click="openEntryDialog(scope.row)">编辑</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-dialog>
<el-dialog v-model="entryDialogVisible" :title="isEdit ? '编辑日程' : '添加日程'" width="500px">
<el-form :model="entryForm" label-width="80px">
<el-form-item label="标题">
<el-input v-model="entryForm.title" placeholder="日程标题"></el-input>
</el-form-item>
<el-form-item label="日期">
<el-date-picker v-model="entryForm.planned_date" type="date" placeholder="选择日期" style="width: 100%;"></el-date-picker>
</el-form-item>
<el-form-item label="平台">
<el-select v-model="entryForm.platform" placeholder="选择平台">
<el-option label="知乎" value="zhihu"></el-option>
<el-option label="微信公众号" value="wechat"></el-option>
<el-option label="小红书" value="xiaohongshu"></el-option>
</el-select>
</el-form-item>
<el-form-item label="关联选题">
<el-select v-model="entryForm.topic_id" placeholder="选择选题" filterable clearable>
<el-option v-for="t in topics" :key="t.id" :label="t.title" :value="t.id"></el-option>
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="entryForm.status">
<el-option label="待发布" value="planned"></el-option>
<el-option label="已发布" value="published"></el-option>
<el-option label="延迟" value="delayed"></el-option>
<el-option label="取消" value="cancelled"></el-option>
</el-select>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="entryForm.notes" type="textarea" rows="3" placeholder="备注信息"></el-input>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="entryDialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveEntry" :loading="saving">保存</el-button>
<el-button v-if="isEdit" type="danger" @click="deleteEntry" :loading="saving">删除</el-button>
</template>
</el-dialog>
</div>
<script src="vue.global.prod.js"></script>
<script src="element-plus.full.js"></script>
<script>
const CalendarApp = {
components: { 'navbar-component': window.NavbarComponent, 'navigation-component': window.NavigationComponent },
setup() {
const currentUser = ref({ username: '' });
const isAdmin = ref(false);
const currentYear = ref(new Date().getFullYear());
const currentMonth = ref(new Date().getMonth() + 1);
const weekDays = ref(['日', '一', '二', '三', '四', '五', '六']);
const entries = ref([]);
const topics = ref([]);
const stats = ref({ planned: 0, published: 0, delayed: 0, cancelled: 0 });
const dayDialogVisible = ref(false);
const entryDialogVisible = ref(false);
const selectedDay = ref(null);
const selectedDayEntries = ref([]);
const isEdit = ref(false);
const saving = ref(false);
const entryForm = ref({
id: null, title: '', planned_date: '', platform: 'zhihu',
topic_id: null, status: 'planned', notes: ''
});
const getToken = () => localStorage.getItem('authToken');
const api = async (path, options = {}) => {
const res = await fetch(path, { ...options, headers: { 'Authorization': 'Bearer ' + getToken(), 'Content-Type': 'application/json', ...options.headers } });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).detail || '请求失败');
return res.json();
};
const calendarDays = computed(() => {
const days = [];
const firstDay = new Date(currentYear.value, currentMonth.value - 1, 1);
const lastDay = new Date(currentYear.value, currentMonth.value, 0);
const startWeek = firstDay.getDay();
const totalDays = lastDay.getDate();
const today = new Date();
for (let i = startWeek - 1; i >= 0; i--) {
const d = new Date(currentYear.value, currentMonth.value - 1, -i);
days.push({ day: d.getDate(), month: d.getMonth() + 1, year: d.getFullYear(), isCurrentMonth: false, isToday: false, entries: [] });
}
for (let i = 1; i <= totalDays; i++) {
const isToday = today.getFullYear() === currentYear.value && today.getMonth() + 1 === currentMonth.value && today.getDate() === i;
const dayEntries = entries.value.filter(e => {
const pd = new Date(e.planned_date);
return pd.getFullYear() === currentYear.value && pd.getMonth() + 1 === currentMonth.value && pd.getDate() === i;
});
days.push({ day: i, month: currentMonth.value, year: currentYear.value, isCurrentMonth: true, isToday, entries: dayEntries });
}
const remaining = 42 - days.length;
for (let i = 1; i <= remaining; i++) {
const d = new Date(currentYear.value, currentMonth.value, i);
days.push({ day: d.getDate(), month: d.getMonth() + 1, year: d.getFullYear(), isCurrentMonth: false, isToday: false, entries: [] });
}
return days;
});
const fetchEntries = async () => {
try { entries.value = await api(`/api/calendar?year=${currentYear.value}&month=${currentMonth.value}`); } catch (e) { console.error(e); }
};
const fetchStats = async () => {
try { stats.value = await api(`/api/calendar/stats?year=${currentYear.value}&month=${currentMonth.value}`); } catch (e) { console.error(e); }
};
const fetchTopics = async () => {
try { const res = await api('/api/topics?limit=100'); topics.value = res; } catch (e) { console.error(e); }
};
const prevMonth = () => { if (currentMonth.value === 1) { currentMonth.value = 12; currentYear.value--; } else { currentMonth.value--; } fetchEntries(); fetchStats(); };
const nextMonth = () => { if (currentMonth.value === 12) { currentMonth.value = 1; currentYear.value++; } else { currentMonth.value++; } fetchEntries(); fetchStats(); };
const goToday = () => { const t = new Date(); currentYear.value = t.getFullYear(); currentMonth.value = t.getMonth() + 1; fetchEntries(); fetchStats(); };
const openDayDialog = (day) => { selectedDay.value = day; selectedDayEntries.value = entries.value.filter(e => { const pd = new Date(e.planned_date); return pd.getFullYear() === day.year && pd.getMonth() + 1 === day.month && pd.getDate() === day.day; }); dayDialogVisible.value = true; };
const openCreateDialog = () => { isEdit.value = false; entryForm.value = { id: null, title: '', planned_date: `${selectedDay.value.year}-${selectedDay.value.month}-${selectedDay.value.day}`, platform: 'zhihu', topic_id: null, status: 'planned', notes: '' }; entryDialogVisible.value = true; };
const openEntryDialog = (entry) => { isEdit.value = true; entryForm.value = { ...entry, planned_date: entry.planned_date }; entryDialogVisible.value = true; };
const saveEntry = async () => {
saving.value = true;
try {
const data = { ...entryForm.value };
if (isEdit.value) { await api(`/api/calendar/entries/${data.id}`, { method: 'PUT', body: JSON.stringify(data) }); }
else { await api('/api/calendar/entries', { method: 'POST', body: JSON.stringify(data) }); }
entryDialogVisible.value = false;
await fetchEntries();
await fetchStats();
} catch (e) { alert('保存失败: ' + e.message); }
saving.value = false;
};
const deleteEntry = async () => {
if (!confirm('确定删除?')) return;
saving.value = true;
try { await api(`/api/calendar/entries/${entryForm.value.id}`, { method: 'DELETE' }); entryDialogVisible.value = false; await fetchEntries(); await fetchStats(); } catch (e) { alert('删除失败: ' + e.message); }
saving.value = false;
};
const platformName = (p) => ({ zhihu: '知乎', wechat: '微信公众号', xiaohongshu: '小红书' }[p] || p);
const statusLabel = (s) => ({ planned: '待发布', published: '已发布', delayed: '延迟', cancelled: '取消' }[s] || s);
const statusType = (s) => ({ planned: 'warning', published: 'success', delayed: 'danger', cancelled: 'info' }[s] || '');
return { currentUser, isAdmin, currentYear, currentMonth, weekDays, calendarDays, entries, topics, stats, dayDialogVisible, entryDialogVisible, selectedDay, selectedDayEntries, isEdit, saving, entryForm, prevMonth, nextMonth, goToday, openDayDialog, openCreateDialog, openEntryDialog, saveEntry, deleteEntry, platformName, statusLabel, statusType };
},
methods: {
handleLogout() { localStorage.removeItem('authToken'); window.location.href = '/'; },
redirectToPage(page) { window.location.href = page + '.html'; }
},
mounted() {
const token = localStorage.getItem('authToken');
if (!token) { window.location.href = '/'; return; }
fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token } })
.then(r => r.ok ? r.json() : Promise.reject())
.then(d => { this.currentUser = d.user; this.isAdmin = d.user.role === 'admin'; this.fetchEntries(); this.fetchStats(); this.fetchTopics(); })
.catch(() => { localStorage.removeItem('authToken'); window.location.href = '/'; });
}
};
const app = Vue.createApp(CalendarApp);
app.use(ElementPlus);
if (window.installNavbar) { window.installNavbar(app); }
if (window.installNavigation) { window.installNavigation(app); } else if (window.NavigationComponent) { app.component("navigation-component", window.NavigationComponent); }
app.mount('#app');
</script>
</body>
</html>
+231
View File
@@ -0,0 +1,231 @@
<!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">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; }
.navbar { background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%); color: white; padding: 16px 24px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
.navbar-content { display: flex; justify-content: space-between; align-items: center; max-width: 1400px; margin: 0 auto; }
.navbar-title { font-size: 20px; font-weight: 600; }
.navbar-user { display: flex; align-items: center; gap: 16px; }
.user-info { display: flex; align-items: center; gap: 8px; }
.avatar { width: 32px; height: 32px; border-radius: 50%; background: rgba(255,255,255,0.2); display: flex; align-items: center; justify-content: center; font-size: 14px; }
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; width: 100%; }
.sidebar { width: 180px; background: white; padding: 12px; box-shadow: 2px 0 8px rgba(0,0,0,0.05); }
.content-area { flex: 1; padding: 24px; overflow-y: auto; }
.card { background: white; border-radius: 12px; padding: 24px; margin-bottom: 24px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
.stat-card { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 12px; padding: 20px; color: white; text-align: center; }
.stat-card.success { background: linear-gradient(135deg, #67c23a 0%, #85ce61 100%); }
.stat-card.warning { background: linear-gradient(135deg, #e6a23c 0%, #f5c543 100%); }
.stat-card.danger { background: linear-gradient(135deg, #f56c6c 0%, #f78989 100%); }
.stat-value { font-size: 32px; font-weight: 700; }
.stat-label { font-size: 14px; opacity: 0.9; margin-top: 4px; }
.mobile-nav { display: none; position: fixed; bottom: 0; left: 0; right: 0; background: white; box-shadow: 0 -2px 8px rgba(0,0,0,0.1); padding: 8px 0; z-index: 1000; }
@media (max-width: 768px) {
.sidebar { display: none; }
.mobile-nav { display: flex; }
.content-area { padding: 16px; padding-bottom: 80px; }
.stats-grid { grid-template-columns: repeat(2, 1fr) !important; gap: 12px !important; }
.chart-container { height: 250px !important; }
}
.stats-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 20px; margin-bottom: 24px; }
.chart-container { background: white; border-radius: 12px; padding: 20px; margin-bottom: 24px; height: 350px; }
.platform-chart { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; }
@media (max-width: 768px) {
.platform-chart { grid-template-columns: 1fr; }
}
</style>
<script src="navigation-component.js"></script>
<script src="navbar-component.js"></script>
</head>
<body>
<div id="app">
<navbar-component title="数据分析" :username="currentUser.username" :is-admin="isAdmin" @logout="handleLogout"></navbar-component>
<navigation-component current-page="metrics" :is-admin="isAdmin" @navigate="redirectToPage"></navigation-component>
<div class="main-content">
<main class="content-area">
<h2 style="font-size: 24px; font-weight: 700; margin-bottom: 24px; color: #303133;">📊 数据分析</h2>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value">{{ dashboard.total_topics }}</div>
<div class="stat-label">选题总数</div>
</div>
<div class="stat-card success">
<div class="stat-value">{{ dashboard.total_published }}</div>
<div class="stat-label">已发布</div>
</div>
<div class="stat-card warning">
<div class="stat-value">{{ dashboard.total_views }}</div>
<div class="stat-label">总阅读</div>
</div>
<div class="stat-card danger">
<div class="stat-value">{{ dashboard.avg_engagement_rate }}%</div>
<div class="stat-label">平均互动率</div>
</div>
</div>
<div class="card">
<h3 style="font-size: 18px; margin-bottom: 16px;">📈 选题状态分布</h3>
<div style="display: flex; gap: 20px; flex-wrap: wrap;">
<div v-for="(count, status) in dashboard.topics_by_status" :key="status" style="text-align: center;">
<div style="font-size: 28px; font-weight: 700; color: #409eff;">{{ count }}</div>
<div style="font-size: 14px; color: #909399;">{{ getStatusLabel(status) }}</div>
</div>
</div>
</div>
<div class="card">
<h3 style="font-size: 18px; margin-bottom: 16px;">🔥 热门选题 TOP10</h3>
<el-table :data="dashboard.top_topics" stripe size="small">
<el-table-column prop="topic_id" label="ID" width="80"></el-table-column>
<el-table-column prop="title" label="标题"></el-table-column>
<el-table-column prop="total_views" label="阅读" width="100">
<template #default="scope">{{ scope.row.total_views || 0 }}</template>
</el-table-column>
<el-table-column prop="total_likes" label="点赞" width="100">
<template #default="scope">{{ scope.row.total_likes || 0 }}</template>
</el-table-column>
</el-table>
</div>
<div class="card">
<h3 style="font-size: 18px; margin-bottom: 16px;">📉 数据趋势</h3>
<div style="display: flex; gap: 12px; margin-bottom: 16px; flex-wrap: wrap;">
<el-button-group>
<el-button :type="trendDays === 7 ? 'primary' : ''" @click="trendDays = 7; fetchTrend()">7天</el-button>
<el-button :type="trendDays === 30 ? 'primary' : ''" @click="trendDays = 30; fetchTrend()">30天</el-button>
<el-button :type="trendDays === 90 ? 'primary' : ''" @click="trendDays = 90; fetchTrend()">90天</el-button>
</el-button-group>
</div>
<div class="chart-container" style="overflow-x: auto;">
<div style="min-width: 600px;">
<div v-for="(item, idx) in trendData" :key="idx" style="display: flex; align-items: center; margin-bottom: 12px; gap: 16px;">
<div style="width: 100px; font-size: 13px; color: #606266;">{{ item.period }}</div>
<div style="flex: 1; background: #f0f9eb; border-radius: 4px; height: 24px; position: relative;">
<div :style="{ width: (item.views / maxViews * 100) + '%', background: '#67c23a', height: '100%', borderRadius: '4px', transition: 'width 0.3s' }"></div>
</div>
<div style="width: 80px; font-size: 13px; text-align: right;">{{ item.views || 0 }} 阅读</div>
</div>
<div v-if="trendData.length === 0" style="text-align: center; color: #909399; padding: 40px;">暂无数据</div>
</div>
</div>
</div>
<div class="card">
<h3 style="font-size: 18px; margin-bottom: 16px;">🏆 平台对比</h3>
<el-table :data="platformData" stripe size="small">
<el-table-column prop="platform" label="平台" width="120">
<template #default="scope">{{ getPlatformName(scope.row.platform) }}</template>
</el-table-column>
<el-table-column prop="count" label="文章数" width="100"></el-table-column>
<el-table-column prop="total_views" label="总阅读" width="120"></el-table-column>
<el-table-column prop="avg_views" label="平均阅读" width="120">
<template #default="scope">{{ Math.round(scope.row.avg_views || 0) }}</template>
</el-table-column>
<el-table-column prop="total_likes" label="总点赞" width="120"></el-table-column>
<el-table-column prop="avg_likes" label="平均点赞" width="120">
<template #default="scope">{{ Math.round(scope.row.avg_likes || 0) }}</template>
</el-table-column>
</el-table>
</div>
<div class="card">
<h3 style="font-size: 18px; margin-bottom: 16px;">💡 选题推荐</h3>
<el-table :data="recommendations" stripe size="small">
<el-table-column prop="topic_id" label="ID" width="80"></el-table-column>
<el-table-column prop="title" label="推荐选题"></el-table-column>
<el-table-column prop="field" label="领域" width="120"></el-table-column>
<el-table-column prop="avg_engagement" label="互动率" width="100">
<template #default="scope">{{ scope.row.avg_engagement }}%</template>
</el-table-column>
<el-table-column prop="max_views" label="最高阅读" width="120"></el-table-column>
<el-table-column prop="reason" label="推荐理由"></el-table-column>
</el-table>
</div>
</main>
</div>
</div>
<script src="vue.global.prod.js"></script>
<script src="element-plus.full.js"></script>
<script>
const MetricsApp = {
data() {
return {
currentUser: { username: '' },
isAdmin: false,
isLoggedIn: false,
dashboard: { total_topics: 0, topics_by_status: {}, total_published: 0, total_views: 0, total_likes: 0, avg_engagement_rate: 0, top_topics: [], recent_metrics: [] },
trendDays: 30,
trendData: [],
maxViews: 1,
platformData: [],
recommendations: []
}
},
methods: {
handleLogout() { localStorage.removeItem('authToken'); window.location.href = '/'; },
redirectToPage(page) { window.location.href = page; },
getStatusLabel(status) {
const map = { 'pending': '待处理', 'review': '待审查', 'ready': '待发布', 'published': '已发布' };
return map[status] || status;
},
getPlatformName(platform) {
const map = { 'zhihu': '知乎', 'wechat': '微信公众号', 'xiaohongshu': '小红书' };
return map[platform] || platform;
},
async fetchDashboard() {
try {
const token = localStorage.getItem('authToken');
const res = await fetch('/api/metrics/dashboard?days=' + this.trendDays, { headers: { 'Authorization': 'Bearer ' + token } });
if (res.ok) this.dashboard = await res.json();
} catch (e) { console.error(e); }
},
async fetchTrend() {
try {
const token = localStorage.getItem('authToken');
const res = await fetch('/api/metrics/trend?days=' + this.trendDays, { headers: { 'Authorization': 'Bearer ' + token } });
if (res.ok) {
this.trendData = await res.json();
this.maxViews = Math.max(...this.trendData.map(t => t.views || 0), 1);
}
} catch (e) { console.error(e); }
},
async fetchPlatformData() {
try {
const token = localStorage.getItem('authToken');
const res = await fetch('/api/metrics/by-platform', { headers: { 'Authorization': 'Bearer ' + token } });
if (res.ok) this.platformData = await res.json();
} catch (e) { console.error(e); }
},
async fetchRecommendations() {
try {
const token = localStorage.getItem('authToken');
const res = await fetch('/api/metrics/recommend-topics?limit=10', { headers: { 'Authorization': 'Bearer ' + token } });
if (res.ok) this.recommendations = await res.json();
} catch (e) { console.error(e); }
}
},
mounted() {
const token = localStorage.getItem('authToken');
if (!token) { window.location.href = '/'; return; }
fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token } })
.then(r => r.ok ? r.json() : Promise.reject())
.then(data => {
this.currentUser = data.user;
this.isAdmin = data.user.role === 'admin';
this.isLoggedIn = true;
this.fetchDashboard();
this.fetchTrend();
this.fetchPlatformData();
this.fetchRecommendations();
})
.catch(() => { localStorage.removeItem('authToken'); window.location.href = '/'; });
}
};
const app = Vue.createApp(MetricsApp);
app.use(ElementPlus);
if (window.installNavbar) { window.installNavbar(app); }
if (window.installNavigation) { window.installNavigation(app); } else if (window.NavigationComponent) { app.component("navigation-component", window.NavigationComponent); }
app.mount('#app');
</script>
</body>
</html>
+8 -3
View File
@@ -35,6 +35,11 @@
<nav class="sidebar-nav">
<button class="sidebar-btn ${currentPage==='dashboard'?'active':''}" data-page="/">📊 系统概览</button>
<button class="sidebar-btn ${currentPage==='topics'?'active':''}" data-page="topics.html">📋 选题管理</button>
<button class="sidebar-btn ${currentPage==='metrics'?'active':''}" data-page="metrics.html">📊 数据分析</button>
<button class="sidebar-btn ${currentPage==='calendar'?'active':''}" data-page="calendar.html">📅 内容日历</button>
<button class="sidebar-btn ${currentPage==='assets'?'active':''}" data-page="assets.html">🖼️ 素材库</button>
<button class="sidebar-btn ${currentPage==='tasks'?'active':''}" data-page="tasks.html">🚀 创作任务</button>
<button class="sidebar-btn ${currentPage==='platforms'?'active':''}" data-page="platforms.html">🌐 平台配置</button>
<button class="sidebar-btn ${currentPage==='logs'?'active':''}" data-page="logs.html">📄 系统日志</button>
${isAdmin ? `<button class="sidebar-btn ${currentPage==='users'?'active':''}" data-page="users.html">👥 用户管理</button>` : ''}
${isAdmin ? `<button class="sidebar-btn ${currentPage==='admin'?'active':''}" data-page="admin.html">⚙️ 系统管理</button>` : ''}
@@ -47,9 +52,9 @@
mobileNav.innerHTML = `
<button class="mobile-nav-btn ${currentPage==='dashboard'?'active':''}" data-page="/">📊</button>
<button class="mobile-nav-btn ${currentPage==='topics'?'active':''}" data-page="topics.html">📋</button>
<button class="mobile-nav-btn ${currentPage==='logs'?'active':''}" data-page="logs.html">📄</button>
${isAdmin ? `<button class="mobile-nav-btn ${currentPage==='users'?'active':''}" data-page="users.html">👥</button>` : ''}
${isAdmin ? `<button class="mobile-nav-btn ${currentPage==='admin'?'active':''}" data-page="admin.html">⚙️</button>` : ''}
<button class="mobile-nav-btn ${currentPage==='metrics'?'active':''}" data-page="metrics.html">📊</button>
<button class="mobile-nav-btn ${currentPage==='assets'?'active':''}" data-page="assets.html">🖼️</button>
<button class="mobile-nav-btn ${currentPage==='tasks'?'active':''}" data-page="tasks.html">🚀</button>
`;
wrapper.appendChild(sidebar);
+207
View File
@@ -0,0 +1,207 @@
<!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">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; }
.navbar { background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%); color: white; padding: 16px 24px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
.navbar-content { display: flex; justify-content: space-between; align-items: center; max-width: 1400px; margin: 0 auto; }
.navbar-title { font-size: 20px; font-weight: 600; }
.navbar-user { display: flex; align-items: center; gap: 16px; }
.user-info { display: flex; align-items: center; gap: 8px; }
.avatar { width: 32px; height: 32px; border-radius: 50%; background: rgba(255,255,255,0.2); display: flex; align-items: center; justify-content: center; font-size: 14px; }
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; width: 100%; }
.sidebar { width: 180px; background: white; padding: 12px; box-shadow: 2px 0 8px rgba(0,0,0,0.05); }
.content-area { flex: 1; padding: 24px; overflow-y: auto; }
.card { background: white; border-radius: 12px; padding: 24px; margin-bottom: 24px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
.mobile-nav { display: none; position: fixed; bottom: 0; left: 0; right: 0; background: white; box-shadow: 0 -2px 8px rgba(0,0,0,0.1); padding: 8px 0; z-index: 1000; }
.platform-card { border: 1px solid #ebeef5; border-radius: 12px; padding: 20px; margin-bottom: 16px; transition: all 0.3s; }
.platform-card:hover { border-color: #409eff; box-shadow: 0 2px 12px rgba(64,158,255,0.15); }
.platform-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.platform-name { font-size: 18px; font-weight: 600; display: flex; align-items: center; gap: 8px; }
.platform-badge { padding: 4px 12px; border-radius: 16px; font-size: 12px; }
.active-badge { background: #f0f9eb; color: #67c23a; }
.inactive-badge { background: #f4f4f5; color: #909399; }
.platform-info { font-size: 14px; color: #606266; margin-bottom: 12px; }
.rule-item { padding: 8px 12px; background: #f5f7fa; border-radius: 4px; margin-bottom: 8px; font-size: 13px; }
@media (max-width: 768px) {
.sidebar { display: none; }
.mobile-nav { display: flex; }
.content-area { padding: 16px; padding-bottom: 80px; }
}
</style>
<script src="navigation-component.js"></script>
<script src="navbar-component.js"></script>
</head>
<body>
<div id="app">
<navbar-component title="平台配置" :username="currentUser.username" :is-admin="isAdmin" @logout="handleLogout"></navbar-component>
<navigation-component current-page="platforms" :is-admin="isAdmin" @navigate="redirectToPage"></navigation-component>
<div class="main-content">
<main class="content-area">
<h2 style="font-size: 24px; font-weight: 700; margin-bottom: 24px; color: #303133;">🌐 平台配置</h2>
<div class="card">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; flex-wrap: wrap; gap: 12px;">
<el-button-group>
<el-button :type="showActiveOnly ? 'primary' : ''" @click="showActiveOnly = true; loadPlatforms()">启用中</el-button>
<el-button :type="!showActiveOnly ? 'primary' : ''" @click="showActiveOnly = false; loadPlatforms()">全部</el-button>
</el-button-group>
<el-button @click="loadPlatforms">🔄 刷新</el-button>
</div>
<div v-if="loading" style="text-align: center; padding: 40px;">加载中...</div>
<div v-else-if="platforms.length === 0" style="text-align: center; padding: 40px; color: #909399;">
<div style="font-size: 48px; margin-bottom: 16px;">🌐</div>
<div>暂无平台配置</div>
</div>
<div v-else>
<div v-for="p in platforms" :key="p.platform" class="platform-card">
<div class="platform-header">
<div class="platform-name">
<span>{{ getPlatformIcon(p.platform) }}</span>
{{ getPlatformName(p.platform) }}
</div>
<div style="display: flex; gap: 8px; align-items: center;">
<span class="platform-badge" :class="p.is_active ? 'active-badge' : 'inactive-badge'">
{{ p.is_active ? '✓ 启用' : '○ 停用' }}
</span>
<el-button size="small" type="primary" @click="editPlatform(p)">编辑</el-button>
</div>
</div>
<div class="platform-info">
<div style="margin-bottom: 4px;">平台标识: {{ p.platform }}</div>
<div v-if="p.platform_name">平台名称: {{ p.platform_name }}</div>
</div>
<div v-if="p.format_rules && Object.keys(p.format_rules).length > 0">
<div style="font-weight: 600; margin-bottom: 8px;">格式规则:</div>
<div v-for="(rule, key) in p.format_rules" :key="key" class="rule-item">
<strong>{{ key }}:</strong> {{ typeof rule === 'object' ? JSON.stringify(rule) : rule }}
</div>
</div>
<div v-if="p.compliance_rules && p.compliance_rules.length > 0">
<div style="font-weight: 600; margin-bottom: 8px;">合规规则:</div>
<div v-for="(rule, idx) in p.compliance_rules" :key="idx" class="rule-item">{{ rule }}</div>
</div>
</div>
</div>
</div>
</main>
</div>
<el-dialog v-model="showEditDialog" :title="isEditing ? '编辑平台' : '新增平台'" width="600px">
<el-form :model="platformForm" label-width="100px">
<el-form-item label="平台标识" :required="!isEditing">
<el-input v-model="platformForm.platform" :disabled="isEditing" placeholder="如: zhihu, wechat, xiaohongshu"></el-input>
</el-form-item>
<el-form-item label="平台名称">
<el-input v-model="platformForm.platform_name" placeholder="如: 知乎"></el-input>
</el-form-item>
<el-form-item label="启用状态">
<el-switch v-model="platformForm.is_active"></el-switch>
</el-form-item>
<el-form-item label="标题模板">
<el-input v-model="platformForm.title_template" placeholder="如: 【{{tag}}】{{title}}"></el-input>
</el-form-item>
<el-form-item label="正文模板">
<el-input v-model="platformForm.body_template" type="textarea" :rows="4" placeholder="如: {{content}}"></el-input>
</el-form-item>
<el-form-item label="字数限制">
<el-input-number v-model="platformForm.min_words" :min="0" placeholder="最少"></el-input-number>
<span style="margin: 0 8px;">-</span>
<el-input-number v-model="platformForm.max_words" :min="0" placeholder="最多"></el-input-number>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showEditDialog = false">取消</el-button>
<el-button type="primary" @click="savePlatform" :loading="saving">保存</el-button>
</template>
</el-dialog>
</div>
<script src="vue.global.prod.js"></script>
<script src="element-plus.full.js"></script>
<script>
const PlatformsApp = {
data() {
return {
currentUser: { username: '' },
isAdmin: false,
isLoggedIn: false,
platforms: [],
loading: false,
showActiveOnly: true,
showEditDialog: false,
isEditing: false,
saving: false,
platformForm: { platform: '', platform_name: '', is_active: true, title_template: '', body_template: '', min_words: null, max_words: null }
}
},
methods: {
handleLogout() { localStorage.removeItem('authToken'); window.location.href = '/'; },
redirectToPage(page) { window.location.href = page; },
getPlatformIcon(platform) {
const map = { 'zhihu': '💬', 'wechat': '💌', 'xiaohongshu': '📕', 'weibo': '🌐' };
return map[platform] || '🌐';
},
getPlatformName(platform) {
const map = { 'zhihu': '知乎', 'wechat': '微信公众号', 'xiaohongshu': '小红书', 'weibo': '微博' };
return map[platform] || platform;
},
async loadPlatforms() {
this.loading = true;
try {
const token = localStorage.getItem('authToken');
const res = await fetch('/api/platform-config?active_only=' + this.showActiveOnly, { headers: { 'Authorization': 'Bearer ' + token } });
if (res.ok) this.platforms = await res.json();
} catch (e) { console.error(e); }
finally { this.loading = false; }
},
editPlatform(p) {
this.isEditing = true;
this.platformForm = {
platform: p.platform,
platform_name: p.platform_name || '',
is_active: p.is_active,
title_template: p.title_template || '',
body_template: p.body_template || '',
min_words: p.min_words,
max_words: p.max_words
};
this.showEditDialog = true;
},
async savePlatform() {
this.saving = true;
try {
const token = localStorage.getItem('authToken');
const url = this.isEditing ? '/api/platform-config/' + this.platformForm.platform : '/api/platform-config';
const method = this.isEditing ? 'PUT' : 'POST';
const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token }, body: JSON.stringify(this.platformForm) });
if (res.ok) { this.$message.success('保存成功'); this.showEditDialog = false; this.loadPlatforms(); }
else { const data = await res.json(); throw new Error(data.detail || '保存失败'); }
} catch (e) { this.$message.error(e.message); }
finally { this.saving = false; }
}
},
mounted() {
const token = localStorage.getItem('authToken');
if (!token) { window.location.href = '/'; return; }
fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token } })
.then(r => r.ok ? r.json() : Promise.reject())
.then(data => {
this.currentUser = data.user;
this.isAdmin = data.user.role === 'admin';
this.isLoggedIn = true;
this.loadPlatforms();
})
.catch(() => { localStorage.removeItem('authToken'); window.location.href = '/'; });
}
};
const app = Vue.createApp(PlatformsApp);
app.use(ElementPlus);
if (window.installNavbar) { window.installNavbar(app); }
if (window.installNavigation) { window.installNavigation(app); } else if (window.NavigationComponent) { app.component("navigation-component", window.NavigationComponent); }
app.mount('#app');
</script>
</body>
</html>
+208
View File
@@ -0,0 +1,208 @@
<!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">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; }
.navbar { background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%); color: white; padding: 16px 24px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
.navbar-content { display: flex; justify-content: space-between; align-items: center; max-width: 1400px; margin: 0 auto; }
.navbar-title { font-size: 20px; font-weight: 600; }
.navbar-user { display: flex; align-items: center; gap: 16px; }
.user-info { display: flex; align-items: center; gap: 8px; }
.avatar { width: 32px; height: 32px; border-radius: 50%; background: rgba(255,255,255,0.2); display: flex; align-items: center; justify-content: center; font-size: 14px; }
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; width: 100%; }
.sidebar { width: 180px; background: white; padding: 12px; box-shadow: 2px 0 8px rgba(0,0,0,0.05); }
.content-area { flex: 1; padding: 24px; overflow-y: auto; }
.card { background: white; border-radius: 12px; padding: 24px; margin-bottom: 24px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
.mobile-nav { display: none; position: fixed; bottom: 0; left: 0; right: 0; background: white; box-shadow: 0 -2px 8px rgba(0,0,0,0.1); padding: 8px 0; z-index: 1000; }
.task-card { border: 1px solid #ebeef5; border-radius: 12px; padding: 16px; margin-bottom: 12px; transition: all 0.3s; }
.task-card:hover { border-color: #409eff; box-shadow: 0 2px 12px rgba(64,158,255,0.15); }
.task-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.task-id { font-size: 13px; color: #909399; }
.task-stage { display: inline-block; padding: 4px 12px; border-radius: 16px; font-size: 12px; font-weight: 500; }
.stage-creator { background: #f0f9eb; color: #67c23a; }
.stage-optimize { background: #fdf6ec; color: #e6a23c; }
.stage-review { background: #fef0f0; color: #f56c6c; }
.stage-publish { background: #ecf5ff; color: #409eff; }
.task-body { margin-bottom: 12px; }
.task-message { font-size: 14px; color: #606266; margin-bottom: 8px; }
.task-meta { display: flex; gap: 16px; font-size: 13px; color: #909399; flex-wrap: wrap; }
@media (max-width: 768px) {
.sidebar { display: none; }
.mobile-nav { display: flex; }
.content-area { padding: 16px; padding-bottom: 80px; }
}
.status-badge { display: inline-flex; align-items: center; gap: 4px; padding: 4px 12px; border-radius: 16px; font-size: 13px; }
.status-pending { background: #f4f4f5; color: #909399; }
.status-running { background: #f0f9eb; color: #67c23a; }
.status-completed { background: #ecf5ff; color: #409eff; }
.status-failed { background: #fef0f0; color: #f56c6c; }
.status-cancelled { background: #f4f4f5; color: #c0c4cc; }
</style>
<script src="navigation-component.js"></script>
<script src="navbar-component.js"></script>
</head>
<body>
<div id="app">
<navbar-component title="创作任务" :username="currentUser.username" :is-admin="isAdmin" @logout="handleLogout"></navbar-component>
<navigation-component current-page="tasks" :is-admin="isAdmin" @navigate="redirectToPage"></navigation-component>
<div class="main-content">
<main class="content-area">
<h2 style="font-size: 24px; font-weight: 700; margin-bottom: 24px; color: #303133;">🚀 创作任务</h2>
<div class="card">
<div style="display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 16px;">
<el-button-group>
<el-button :type="filterStatus === '' ? 'primary' : ''" @click="filterStatus = ''; loadTasks()">全部</el-button>
<el-button :type="filterStatus === 'running' ? 'primary' : ''" @click="filterStatus = 'running'; loadTasks()">进行中</el-button>
<el-button :type="filterStatus === 'pending' ? 'primary' : ''" @click="filterStatus = 'pending'; loadTasks()">等待中</el-button>
<el-button :type="filterStatus === 'completed' ? 'primary' : ''" @click="filterStatus = 'completed'; loadTasks()">已完成</el-button>
<el-button :type="filterStatus === 'failed' ? 'primary' : ''" @click="filterStatus = 'failed'; loadTasks()">失败</el-button>
</el-button-group>
<el-button @click="loadTasks">🔄 刷新</el-button>
</div>
<div v-if="loading" style="text-align: center; padding: 40px;">加载中...</div>
<div v-else-if="tasks.length === 0" style="text-align: center; padding: 40px; color: #909399;">
<div style="font-size: 48px; margin-bottom: 16px;">📋</div>
<div>暂无任务</div>
</div>
<div v-else>
<div v-for="task in tasks" :key="task.task_id" class="task-card">
<div class="task-header">
<div style="display: flex; align-items: center; gap: 12px;">
<span class="task-id">#{{ task.task_id }}</span>
<span class="status-badge" :class="'status-' + task.status">{{ getStatusLabel(task.status) }}</span>
<span class="task-stage" :class="'stage-' + task.stage">{{ getStageLabel(task.stage) }}</span>
</div>
<div style="display: flex; gap: 8px;">
<el-button v-if="task.status === 'running'" size="small" type="danger" @click="cancelTask(task.task_id)">取消</el-button>
<el-button v-if="task.status === 'failed'" size="small" type="primary" @click="retryTask(task)">重试</el-button>
<el-button size="small" type="info" @click="viewTaskDetail(task)">详情</el-button>
</div>
</div>
<div class="task-body">
<div class="task-message">{{ task.message || '无消息' }}</div>
<div v-if="task.status === 'running'" style="margin-top: 8px;">
<el-progress :percentage="task.progress || 0" :stroke-width="12" :show-text="true"></el-progress>
</div>
</div>
<div class="task-meta">
<span>创建: {{ formatDate(task.created_at) }}</span>
<span v-if="task.started_at">开始: {{ formatDate(task.started_at) }}</span>
<span v-if="task.finished_at">完成: {{ formatDate(task.finished_at) }}</span>
<span v-if="task.duration">耗时: {{ task.duration }}秒</span>
<span>创建人: {{ task.created_by }}</span>
</div>
<div v-if="task.error_msg" style="margin-top: 8px; padding: 8px; background: #fef0f0; border-radius: 4px; font-size: 13px; color: #f56c6c;">
错误: {{ task.error_msg }}
</div>
</div>
</div>
</div>
</main>
</div>
<el-dialog v-model="showDetailDialog" title="任务详情" width="600px">
<div v-if="detailTask">
<el-descriptions :column="2" border>
<el-descriptions-item label="任务ID">{{ detailTask.task_id }}</el-descriptions-item>
<el-descriptions-item label="状态">{{ getStatusLabel(detailTask.status) }}</el-descriptions-item>
<el-descriptions-item label="阶段">{{ getStageLabel(detailTask.stage) }}</el-descriptions-item>
<el-descriptions-item label="进度">{{ detailTask.progress }}%</el-descriptions-item>
<el-descriptions-item label="选题ID" :span="2">{{ detailTask.topic_id || '-' }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatDate(detailTask.created_at) }}</el-descriptions-item>
<el-descriptions-item label="开始时间">{{ formatDate(detailTask.started_at) }}</el-descriptions-item>
<el-descriptions-item label="完成时间">{{ formatDate(detailTask.finished_at) }}</el-descriptions-item>
<el-descriptions-item label="耗时">{{ detailTask.duration ? detailTask.duration + '秒' : '-' }}</el-descriptions-item>
<el-descriptions-item label="创建人">{{ detailTask.created_by }}</el-descriptions-item>
<el-descriptions-item label="消息" :span="2">{{ detailTask.message || '-' }}</el-descriptions-item>
<el-descriptions-item v-if="detailTask.error_msg" label="错误信息" :span="2">
<span style="color: #f56c6c;">{{ detailTask.error_msg }}</span>
</el-descriptions-item>
</el-descriptions>
<div v-if="detailTask.result_data" style="margin-top: 16px;">
<div style="font-weight: 600; margin-bottom: 8px;">结果数据:</div>
<pre style="background: #f5f7fa; padding: 12px; border-radius: 8px; overflow: auto; max-height: 300px; font-size: 13px;">{{ JSON.stringify(detailTask.result_data, null, 2) }}</pre>
</div>
</div>
</el-dialog>
</div>
<script src="vue.global.prod.js"></script>
<script src="element-plus.full.js"></script>
<script>
const TasksApp = {
data() {
return {
currentUser: { username: '' },
isAdmin: false,
isLoggedIn: false,
tasks: [],
loading: false,
filterStatus: '',
showDetailDialog: false,
detailTask: null
}
},
methods: {
handleLogout() { localStorage.removeItem('authToken'); window.location.href = '/'; },
redirectToPage(page) { window.location.href = page; },
getStatusLabel(status) {
const map = { 'pending': '等待中', 'running': '进行中', 'completed': '已完成', 'failed': '失败', 'cancelled': '已取消' };
return map[status] || status;
},
getStageLabel(stage) {
const map = { 'creator': '创作', 'optimize': '优化', 'review': '审查', 'publish': '发布' };
return map[stage] || stage;
},
formatDate(dateStr) {
if (!dateStr) return '-';
return new Date(dateStr).toLocaleString('zh-CN');
},
async loadTasks() {
this.loading = true;
try {
const token = localStorage.getItem('authToken');
let url = '/api/tasks?limit=50';
if (this.filterStatus) url += '&status=' + this.filterStatus;
const res = await fetch(url, { headers: { 'Authorization': 'Bearer ' + token } });
if (res.ok) this.tasks = await res.json();
} catch (e) { console.error(e); }
finally { this.loading = false; }
},
viewTaskDetail(task) { this.detailTask = task; this.showDetailDialog = true; },
async cancelTask(taskId) {
try {
await this.$confirm('确定取消该任务?', '提示', { type: 'warning' });
const token = localStorage.getItem('authToken');
const res = await fetch('/api/tasks/' + taskId, { method: 'DELETE', headers: { 'Authorization': 'Bearer ' + token } });
if (res.ok) { this.$message.success('任务已取消'); this.loadTasks(); }
} catch (e) { if (e !== 'cancel') this.$message.error(e.message || '操作失败'); }
},
retryTask(task) {
this.$message.info('重试功能开发中...');
}
},
mounted() {
const token = localStorage.getItem('authToken');
if (!token) { window.location.href = '/'; return; }
fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token } })
.then(r => r.ok ? r.json() : Promise.reject())
.then(data => {
this.currentUser = data.user;
this.isAdmin = data.user.role === 'admin';
this.isLoggedIn = true;
this.loadTasks();
})
.catch(() => { localStorage.removeItem('authToken'); window.location.href = '/'; });
}
};
const app = Vue.createApp(TasksApp);
app.use(ElementPlus);
if (window.installNavbar) { window.installNavbar(app); }
if (window.installNavigation) { window.installNavigation(app); } else if (window.NavigationComponent) { app.component("navigation-component", window.NavigationComponent); }
app.mount('#app');
</script>
</body>
</html>