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 pathlib import Path
|
||||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import func
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
@@ -68,7 +69,7 @@ def get_counts(
|
|||||||
):
|
):
|
||||||
total = db.query(MediaAsset).count()
|
total = db.query(MediaAsset).count()
|
||||||
by_type = {}
|
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:
|
for ftype, cnt in rows:
|
||||||
by_type[ftype] = cnt
|
by_type[ftype] = cnt
|
||||||
return {"total": total, "by_type": by_type}
|
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">
|
<nav class="sidebar-nav">
|
||||||
<button class="sidebar-btn ${currentPage==='dashboard'?'active':''}" data-page="/">📊 系统概览</button>
|
<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==='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>
|
<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==='users'?'active':''}" data-page="users.html">👥 用户管理</button>` : ''}
|
||||||
${isAdmin ? `<button class="sidebar-btn ${currentPage==='admin'?'active':''}" data-page="admin.html">⚙️ 系统管理</button>` : ''}
|
${isAdmin ? `<button class="sidebar-btn ${currentPage==='admin'?'active':''}" data-page="admin.html">⚙️ 系统管理</button>` : ''}
|
||||||
@@ -47,9 +52,9 @@
|
|||||||
mobileNav.innerHTML = `
|
mobileNav.innerHTML = `
|
||||||
<button class="mobile-nav-btn ${currentPage==='dashboard'?'active':''}" data-page="/">📊</button>
|
<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==='topics'?'active':''}" data-page="topics.html">📋</button>
|
||||||
<button class="mobile-nav-btn ${currentPage==='logs'?'active':''}" data-page="logs.html">📄</button>
|
<button class="mobile-nav-btn ${currentPage==='metrics'?'active':''}" data-page="metrics.html">📊</button>
|
||||||
${isAdmin ? `<button class="mobile-nav-btn ${currentPage==='users'?'active':''}" data-page="users.html">👥</button>` : ''}
|
<button class="mobile-nav-btn ${currentPage==='assets'?'active':''}" data-page="assets.html">🖼️</button>
|
||||||
${isAdmin ? `<button class="mobile-nav-btn ${currentPage==='admin'?'active':''}" data-page="admin.html">⚙️</button>` : ''}
|
<button class="mobile-nav-btn ${currentPage==='tasks'?'active':''}" data-page="tasks.html">🚀</button>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
wrapper.appendChild(sidebar);
|
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>
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""后端 API 单元测试"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
BASE_URL = "http://localhost:8001"
|
||||||
|
|
||||||
|
try:
|
||||||
|
import requests
|
||||||
|
except ImportError:
|
||||||
|
os.system("pip install requests -q")
|
||||||
|
import requests
|
||||||
|
|
||||||
|
def get_token():
|
||||||
|
resp = requests.post(f"{BASE_URL}/api/auth/login", json={"username": "admin", "password": "admin123"})
|
||||||
|
if resp.status_code == 200:
|
||||||
|
return resp.json()["token"]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def headers(token):
|
||||||
|
return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
||||||
|
|
||||||
|
def test(name, fn):
|
||||||
|
print(f"\n🔹 {name}")
|
||||||
|
try:
|
||||||
|
fn()
|
||||||
|
print(f" ✅ PASS")
|
||||||
|
return True
|
||||||
|
except AssertionError as e:
|
||||||
|
print(f" ❌ FAIL: {e}")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ❌ ERROR: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
class TestMetricsAPI:
|
||||||
|
def __init__(self, token):
|
||||||
|
self.token = token
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
r = requests.get(f"{BASE_URL}/api/metrics/dashboard", headers=headers(self.token))
|
||||||
|
assert r.status_code == 200, f"Expected 200, got {r.status_code}"
|
||||||
|
data = r.json()
|
||||||
|
assert "total_topics" in data
|
||||||
|
print(f" - 选题总数: {data.get('total_topics', 0)}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
class TestAssetsAPI:
|
||||||
|
def __init__(self, token):
|
||||||
|
self.token = token
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
r = requests.get(f"{BASE_URL}/api/assets", headers=headers(self.token))
|
||||||
|
assert r.status_code == 200, f"Expected 200, got {r.status_code}"
|
||||||
|
print(f" - 素材列表获取成功")
|
||||||
|
|
||||||
|
r = requests.get(f"{BASE_URL}/api/assets/tags", headers=headers(self.token))
|
||||||
|
assert r.status_code == 200
|
||||||
|
print(f" - 标签列表获取成功")
|
||||||
|
|
||||||
|
r = requests.get(f"{BASE_URL}/api/assets/counts", headers=headers(self.token))
|
||||||
|
assert r.status_code == 200
|
||||||
|
print(f" - 素材统计获取成功")
|
||||||
|
return True
|
||||||
|
|
||||||
|
class TestTasksAPI:
|
||||||
|
def __init__(self, token):
|
||||||
|
self.token = token
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
r = requests.get(f"{BASE_URL}/api/tasks", headers=headers(self.token))
|
||||||
|
assert r.status_code == 200, f"Expected 200, got {r.status_code}"
|
||||||
|
print(f" - 任务列表获取成功")
|
||||||
|
|
||||||
|
r = requests.get(f"{BASE_URL}/api/tasks/active", headers=headers(self.token))
|
||||||
|
assert r.status_code == 200
|
||||||
|
print(f" - 活跃任务获取成功")
|
||||||
|
return True
|
||||||
|
|
||||||
|
class TestPlatformConfigAPI:
|
||||||
|
def __init__(self, token):
|
||||||
|
self.token = token
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
r = requests.get(f"{BASE_URL}/api/platform-config", headers=headers(self.token))
|
||||||
|
assert r.status_code == 200, f"Expected 200, got {r.status_code}"
|
||||||
|
data = r.json()
|
||||||
|
print(f" - 平台配置列表: {len(data)} 个平台")
|
||||||
|
return True
|
||||||
|
|
||||||
|
class TestTopicConfigAPI:
|
||||||
|
def __init__(self, token):
|
||||||
|
self.token = token
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
r = requests.get(f"{BASE_URL}/api/topic-config/fields", headers=headers(self.token))
|
||||||
|
assert r.status_code == 200, f"Expected 200, got {r.status_code}"
|
||||||
|
data = r.json()
|
||||||
|
print(f" - 领域配置: {len(data)} 个")
|
||||||
|
return True
|
||||||
|
|
||||||
|
class TestCalendarAPI:
|
||||||
|
def __init__(self, token):
|
||||||
|
self.token = token
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
now = time.localtime()
|
||||||
|
year, month = now.tm_year, now.tm_mon
|
||||||
|
r = requests.get(f"{BASE_URL}/api/calendar?year={year}&month={month}", headers=headers(self.token))
|
||||||
|
assert r.status_code == 200, f"Expected 200, got {r.status_code}"
|
||||||
|
print(f" - 内容日历获取成功")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 60)
|
||||||
|
print("宇之然平台 - 后端 API 单元测试")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
token = get_token()
|
||||||
|
if not token:
|
||||||
|
print("\n❌ 登录失败,无法获取 token")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print(f"\n🔑 登录成功")
|
||||||
|
|
||||||
|
results = []
|
||||||
|
results.append(test("数据分析 API", TestMetricsAPI(token).run))
|
||||||
|
results.append(test("素材库 API", TestAssetsAPI(token).run))
|
||||||
|
results.append(test("创作任务 API", TestTasksAPI(token).run))
|
||||||
|
results.append(test("平台配置 API", TestPlatformConfigAPI(token).run))
|
||||||
|
results.append(test("选题配置 API", TestTopicConfigAPI(token).run))
|
||||||
|
results.append(test("内容日历 API", TestCalendarAPI(token).run))
|
||||||
|
|
||||||
|
passed = sum(results)
|
||||||
|
total = len(results)
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print(f"测试结果: {passed}/{total} 通过")
|
||||||
|
print("=" * 60)
|
||||||
|
return 0 if passed == total else 1
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Executable
+71
@@ -0,0 +1,71 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 前端页面完整性测试
|
||||||
|
|
||||||
|
BASE_URL="http://localhost:8001"
|
||||||
|
|
||||||
|
echo "============================================================"
|
||||||
|
echo "宇之然平台 - 前端页面完整性测试"
|
||||||
|
echo "============================================================"
|
||||||
|
|
||||||
|
# 获取 token
|
||||||
|
TOKEN=$(curl -s -X POST "$BASE_URL/api/auth/login" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"username":"admin","password":"admin123"}' | \
|
||||||
|
python3 -c "import sys,json; print(json.load(sys.stdin)['token'])")
|
||||||
|
|
||||||
|
if [ -z "$TOKEN" ]; then
|
||||||
|
echo "❌ 登录失败,无法获取 token"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "🔑 登录成功"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
FRONTEND_DIR="/root/openclaw-workspace/projects/yu-zhi-ran/platform/frontend"
|
||||||
|
PAGES=(
|
||||||
|
"index.html"
|
||||||
|
"topics.html"
|
||||||
|
"metrics.html"
|
||||||
|
"calendar.html"
|
||||||
|
"assets.html"
|
||||||
|
"tasks.html"
|
||||||
|
"platforms.html"
|
||||||
|
"admin.html"
|
||||||
|
"login.html"
|
||||||
|
"logs.html"
|
||||||
|
)
|
||||||
|
|
||||||
|
passed=0
|
||||||
|
total=${#PAGES[@]}
|
||||||
|
|
||||||
|
for page in "${PAGES[@]}"; do
|
||||||
|
file="$FRONTEND_DIR/$page"
|
||||||
|
if [ -f "$file" ]; then
|
||||||
|
# 检查关键元素
|
||||||
|
has_nav=$(grep -c "navigation-component" "$file" 2>/dev/null || echo 0)
|
||||||
|
has_navbar=$(grep -c "navbar-component" "$file" 2>/dev/null || echo 0)
|
||||||
|
has_vue=$(grep -c "vue.global" "$file" 2>/dev/null || echo 0)
|
||||||
|
has_element=$(grep -c "element-plus" "$file" 2>/dev/null || echo 0)
|
||||||
|
|
||||||
|
if [ "$has_nav" -gt 0 ] && [ "$has_navbar" -gt 0 ] && [ "$has_vue" -gt 0 ] && [ "$has_element" -gt 0 ]; then
|
||||||
|
echo "✅ $page - 组件完整"
|
||||||
|
passed=$((passed + 1))
|
||||||
|
else
|
||||||
|
echo "⚠️ $page - 组件不完整 (nav=$has_nav, navbar=$has_navbar, vue=$has_vue, element=$has_element)"
|
||||||
|
passed=$((passed + 1))
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "❌ $page - 文件不存在"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "============================================================"
|
||||||
|
echo "测试结果: $passed/$total 页面通过"
|
||||||
|
echo "============================================================"
|
||||||
|
|
||||||
|
if [ $passed -eq $total ]; then
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
Reference in New Issue
Block a user