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:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user