1855f190f5
- 新增 PromptConfig 模型 + API,支持提示词在线编辑(16条默认) - 调度器动态读取 TaskConfig.schedule,admin 可调执行时间 - 新增 KeywordDomainMap、SensitiveWord、ContentCleanRule、TrendFieldMapping 表 - DOMAINS、TREND_DOMAIN_MAP、PLATFORM_TAGS、china_pains、RSS关键词、priority_weights 全部迁移到 DB - tasks.html 重构:卡片网格+配置/产出/历史/提示词四个Tab,折叠显示 - 清理冗余代码:DEFAULT_PROMPTS死代码、collector.py unreachable代码、compliance_checker bug - strip_thinking_html 改用 DB 规则优先
269 lines
18 KiB
HTML
269 lines
18 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>宇之然内容创作平台 - 文章管理</title>
|
||
<link rel="stylesheet" href="element-plus.css">
|
||
<link rel="stylesheet" href="theme-modern.css">
|
||
<style>
|
||
.article-card-list { display: none; }
|
||
@media (max-width: 768px) {
|
||
.el-table { display: none; }
|
||
.article-card-list { display: block; }
|
||
.article-card { background: var(--color-bg-raised); border-radius: var(--radius-md); padding: 12px; margin-bottom: 12px; box-shadow: var(--shadow-sm); }
|
||
.article-card-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 12px; }
|
||
.article-card-title { font-size: var(--font-size-body); font-weight: 600; color: var(--color-text-primary); flex: 1; margin-right: 8px; }
|
||
.article-card-meta { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; font-size: var(--font-size-caption); color: var(--color-text-regular); margin-bottom: 12px; }
|
||
.article-card-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--color-border); }
|
||
.article-card-actions .el-button { margin: 0; width: 100%; justify-content: center; }
|
||
.search-bar { width: 100%; margin-left: 0; }
|
||
}
|
||
</style>
|
||
<script src="uni-nav.js"></script>
|
||
</head>
|
||
<body>
|
||
<div id="app">
|
||
<uni-nav title="文章管理" :username="currentUser.username" :is-admin="isAdmin" current-page="articles" @navigate="redirectToPage" @logout="handleLogout">
|
||
</uni-nav>
|
||
<div class="main-content">
|
||
<main class="content-area">
|
||
<div class="card page-fade">
|
||
<div class="page-header">
|
||
<h2 class="page-title"><el-icon style="vertical-align:-2px;"><IconDocument /></el-icon> 文章管理</h2>
|
||
</div>
|
||
<div class="filter-bar">
|
||
<el-select v-model="filterPlatform" placeholder="全部平台" clearable size="default" style="width:140px;" @change="fetchArticles">
|
||
<el-option label="全部平台" value=""></el-option>
|
||
<el-option label="知乎" value="zhihu"></el-option>
|
||
<el-option label="微信公众号" value="wechat"></el-option>
|
||
<el-option label="小红书" value="xiaohongshu"></el-option>
|
||
</el-select>
|
||
<el-select v-model="filterStatus" placeholder="全部状态" clearable size="default" style="width:140px;" @change="fetchArticles">
|
||
<el-option label="全部状态" value=""></el-option>
|
||
<el-option label="草稿" value="draft"></el-option>
|
||
<el-option label="已审查" value="reviewed"></el-option>
|
||
<el-option label="已发布" value="published"></el-option>
|
||
</el-select>
|
||
<el-input v-model="searchQuery" placeholder="搜索文章 ID / 选题标题" clearable size="default" class="search-bar" @input="debouncedSearch" @clear="fetchArticles">
|
||
<template #prefix><el-icon style="vertical-align:-2px;"><IconSearch /></el-icon></template>
|
||
</el-input>
|
||
</div>
|
||
<el-table :data="filteredArticles" stripe v-loading="loadingTable">
|
||
<el-table-column prop="id" label="文章 ID" width="160"></el-table-column>
|
||
<el-table-column prop="topic_id" label="选题 ID" width="80"></el-table-column>
|
||
<el-table-column prop="topic_title" label="选题标题" min-width="200" show-overflow-tooltip></el-table-column>
|
||
<el-table-column prop="platform" label="平台" width="100">
|
||
<template #default="scope">{{ platformLabel(scope.row.platform) }}</template>
|
||
</el-table-column>
|
||
<el-table-column prop="status" label="状态" width="90">
|
||
<template #default="scope"><span class="status-dot" :class="scope.row.status"></span> {{ statusLabel(scope.row.status) }}</template>
|
||
</el-table-column>
|
||
<el-table-column prop="compliance_score" label="合规分" width="80">
|
||
<template #default="scope">{{ scope.row.compliance_score ?? '-' }}</template>
|
||
</el-table-column>
|
||
<el-table-column prop="word_count" label="字数" width="70">
|
||
<template #default="scope">{{ scope.row.word_count ?? '-' }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="配图" width="60">
|
||
<template #default="scope">
|
||
<span v-if="scope.row.images && scope.row.images.cover" style="cursor:pointer;font-size:16px;" title="有封面图"><el-icon style="vertical-align:-2px;"><IconPicture /></el-icon></span>
|
||
<span v-else style="color:#dcdfe6;">—</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="created_at" label="创建时间" width="140"><template #default="scope">{{ formatDate(scope.row.created_at) }}</template></el-table-column>
|
||
<el-table-column label="操作" width="130" fixed="right">
|
||
<template #default="scope">
|
||
<div style="display: flex; gap: 4px;">
|
||
<el-button size="small" type="primary" @click="previewArticle(scope.row)" style="padding:5px 8px;">预览</el-button>
|
||
<el-button size="small" type="danger" @click="deleteArticle(scope.row)" style="padding:5px 8px;">删除</el-button>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
<div class="article-card-list" v-if="filteredArticles && filteredArticles.length > 0">
|
||
<div v-for="article in filteredArticles" :key="article.id" class="article-card">
|
||
<div class="article-card-header">
|
||
<div class="article-card-title">{{ article.topic_title || article.topic_id }}</div>
|
||
<el-tag :type="statusTagType(article.status)" size="small">{{ statusLabel(article.status) }}</el-tag>
|
||
</div>
|
||
<div class="article-card-meta">
|
||
<div>ID: {{ article.id }}</div>
|
||
<div>平台: {{ platformLabel(article.platform) }}</div>
|
||
<div>合规: {{ article.compliance_score ?? '-' }}</div>
|
||
<div>字数: {{ article.word_count ?? '-' }}</div>
|
||
<div>创建: {{ formatDate(article.created_at) }}</div>
|
||
</div>
|
||
<div class="article-card-actions">
|
||
<el-button size="small" type="primary" @click="previewArticle(article)">预览</el-button>
|
||
<el-button size="small" type="danger" @click="deleteArticle(article)">删除</el-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div v-if="!loadingTable && filteredArticles.length === 0" style="text-align:center;padding:40px 0;color:#909399;font-size:14px;">
|
||
<el-icon><IconDocument /></el-icon> 暂无文章
|
||
</div>
|
||
</div>
|
||
</main>
|
||
</div>
|
||
<el-dialog v-model="previewVisible" title="文章预览" width="85%" :modal-props="{ closeOnClickModal: false }" :before-close="() => previewVisible = false" class="preview-dialog-custom" :fullscreen="previewFullscreen" close-on-press-escape>
|
||
<div v-if="previewArticleData">
|
||
<div style="display:flex; gap:16px; margin-bottom:12px; flex-wrap:wrap;">
|
||
<div style="flex:1; min-width:200px;">
|
||
<h3 style="margin:0; font-size:15px;">{{ previewArticleData.topic_title || previewArticleData.topic_id }}</h3>
|
||
<div style="font-size:12px; color:#909399; margin-top:4px;">
|
||
{{ platformLabel(previewArticleData.platform) }} · {{ statusLabel(previewArticleData.status) }} · {{ previewArticleData.word_count ?? '-' }}字
|
||
</div>
|
||
</div>
|
||
<div v-if="previewArticleData.images && previewArticleData.images.cover" style="flex-shrink:0;">
|
||
<img :src="previewArticleData.images.cover" style="height:80px; border-radius:8px; border:1px solid #ebeef5; object-fit:cover;" alt="封面">
|
||
</div>
|
||
</div>
|
||
<div style="display:flex; gap:8px;">
|
||
<el-button size="small" @click="togglePreviewFullscreen">{{ previewFullscreen ? '退出全屏' : '全屏' }}</el-button>
|
||
<el-button v-if="previewFullscreen" size="small" type="danger" @click="previewVisible = false">关闭</el-button>
|
||
</div>
|
||
</div>
|
||
<div style="max-width: 1000px; margin: 0 auto; width: 100%; height: 100%; display: flex; flex-direction: column;">
|
||
<iframe v-if="previewArticleData.html_content" :srcdoc="previewArticleData.html_content" class="preview-iframe" style="flex:1; min-height:500px; border:1px solid #ebeef5; border-radius:8px; background:#fff; overflow:auto; padding:0; width:100%;" sandbox></iframe>
|
||
<div v-else style="padding:60px 20px; text-align:center; color:#909399; font-size:14px;">该文章暂无 HTML 内容</div>
|
||
</div>
|
||
</div>
|
||
<template #footer>
|
||
<div style="display:flex; justify-content:flex-end; gap:8px; width:100%;">
|
||
<el-button @click="previewVisible = false">关闭</el-button>
|
||
<el-button v-if="previewArticleData && previewArticleData.html_content" type="primary" @click="copyPreviewHtml">复制正文</el-button>
|
||
</div>
|
||
</template>
|
||
</el-dialog>
|
||
</div>
|
||
<script src="vue.global.prod.js"></script>
|
||
<script src="icon-components.js"></script>
|
||
<script src="element-plus.full.js"></script>
|
||
<script>
|
||
const ArticlesApp = {
|
||
data() {
|
||
return {
|
||
isLoggedIn: false, isAdmin: false, currentUser: { username: '' },
|
||
loadingTable: false,
|
||
articles: [],
|
||
filterPlatform: '', filterStatus: '', searchQuery: '',
|
||
previewVisible: false, previewArticleData: null, previewFullscreen: false,
|
||
debounceTimer: null
|
||
}
|
||
},
|
||
computed: {
|
||
filteredArticles() {
|
||
let list = this.articles;
|
||
if (this.filterPlatform) list = list.filter(a => a.platform === this.filterPlatform);
|
||
if (this.filterStatus) list = list.filter(a => a.status === this.filterStatus);
|
||
if (this.searchQuery) {
|
||
const q = this.searchQuery.toLowerCase();
|
||
list = list.filter(a => (a.id && a.id.toLowerCase().includes(q)) || (a.topic_title && a.topic_title.toLowerCase().includes(q)));
|
||
}
|
||
return list;
|
||
}
|
||
},
|
||
methods: {
|
||
getToken() { return localStorage.getItem('authToken'); },
|
||
async api(url, opts = {}) {
|
||
const token = this.getToken();
|
||
if (!token) { this.$message.error('请先登录'); setTimeout(() => window.location.href = '/', 1500); return null; }
|
||
const res = await fetch(url, { headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json', ...opts.headers }, ...opts });
|
||
if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data.detail || `请求失败: ${res.status}`); }
|
||
return res.json();
|
||
},
|
||
async fetchArticles() {
|
||
this.loadingTable = true;
|
||
try {
|
||
const params = new URLSearchParams();
|
||
if (this.filterPlatform) params.set('platform', this.filterPlatform);
|
||
if (this.filterStatus) params.set('status', this.filterStatus);
|
||
const qs = params.toString();
|
||
const data = await this.api('/api/articles/list' + (qs ? '?' + qs : ''));
|
||
this.articles = data.articles || [];
|
||
} catch (error) {
|
||
console.error('获取文章列表失败:', error);
|
||
this.$message.error('获取文章列表失败: ' + error.message);
|
||
this.articles = [];
|
||
} finally { this.loadingTable = false; }
|
||
},
|
||
debouncedSearch() {
|
||
clearTimeout(this.debounceTimer);
|
||
this.debounceTimer = setTimeout(() => { this.fetchArticles(); }, 400);
|
||
},
|
||
async previewArticle(article) {
|
||
this.previewArticleData = null;
|
||
this.previewVisible = true;
|
||
this.previewFullscreen = false;
|
||
try {
|
||
const data = await this.api('/api/articles/detail/' + article.id);
|
||
this.previewArticleData = data;
|
||
} catch (error) {
|
||
this.$message.error('加载文章详情失败: ' + error.message);
|
||
this.previewVisible = false;
|
||
}
|
||
},
|
||
togglePreviewFullscreen() {
|
||
this.previewFullscreen = !this.previewFullscreen;
|
||
this.$nextTick(() => {
|
||
const overlays = document.querySelectorAll('.el-overlay');
|
||
const overlay = overlays[overlays.length - 1];
|
||
if (overlay) overlay.style.zIndex = this.previewFullscreen ? '100000' : '';
|
||
const dialog = document.querySelector('.preview-dialog-custom');
|
||
if (dialog) { dialog.style.zIndex = this.previewFullscreen ? '100001' : ''; }
|
||
});
|
||
},
|
||
copyPreviewHtml() {
|
||
const html = this.previewArticleData?.html_content;
|
||
if (!html) { this.$message.warning('暂无内容可复制'); return; }
|
||
const parser = new DOMParser();
|
||
const doc = parser.parseFromString(html, 'text/html');
|
||
const titleEl = doc.querySelector('h1');
|
||
const title = titleEl ? titleEl.textContent.trim() : (this.previewArticleData.topic_title || '');
|
||
const body = doc.body;
|
||
if (body) body.querySelectorAll('script, style, svg, img, nav, footer, .interaction').forEach(el => el.remove());
|
||
const contentEls = body ? Array.from(body.querySelectorAll('p, h1, h2, h3, h4, li')) : [];
|
||
const text = contentEls.map(el => el.textContent.trim()).filter(t => t && t.length > 1).join('\n\n');
|
||
navigator.clipboard.writeText(`标题:${title}\n\n内容:\n${text}`)
|
||
.then(() => this.$message.success('已复制到剪贴板'))
|
||
.catch(() => this.$message.error('复制失败'));
|
||
},
|
||
async deleteArticle(article) {
|
||
try {
|
||
await this.$confirm(`确定删除文章 [${article.id}]?`, '提示', { type: 'warning' });
|
||
await this.api('/api/articles/' + article.id, { method: 'DELETE' });
|
||
this.$message.success('删除成功');
|
||
await this.fetchArticles();
|
||
} catch (e) { if (e !== 'cancel') this.$message.error('删除失败: ' + (e.message || '未知错误')); }
|
||
},
|
||
handleLogout() { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; },
|
||
redirectToPage(page) { window.location.href = page.startsWith('/') ? page : '/' + page; },
|
||
platformLabel(p) { return { zhihu: '知乎', wechat: '微信公众号', xiaohongshu: '小红书' }[p] || p; },
|
||
statusLabel(s) { return { draft: '草稿', reviewed: '已审查', published: '已发布' }[s] || s; },
|
||
statusTagType(s) { return { draft: 'warning', reviewed: 'success', published: 'info' }[s] || 'primary'; },
|
||
formatDate(dateStr) {
|
||
if (!dateStr) return '-';
|
||
try { return new Date(dateStr.replace(' ', 'T')).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); }
|
||
catch (e) { return dateStr; }
|
||
}
|
||
},
|
||
mounted() {
|
||
const token = localStorage.getItem('authToken');
|
||
if (!token) { window.location.href = '/login.html'; 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.fetchArticles(); })
|
||
.catch(() => { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; });
|
||
}
|
||
};
|
||
const app = Vue.createApp(ArticlesApp);
|
||
app.use(ElementPlus);
|
||
if (window.installIcons) { window.installIcons(app); }
|
||
if (window.installUniNav) { window.installUniNav(app); }
|
||
app.mount('#app');
|
||
</script>
|
||
<script src="ai-assistant.js"></script>
|
||
</body>
|
||
</html>
|